diff --git a/.agents/skills/open-pr2/SKILL.md b/.agents/skills/open-pr2/SKILL.md index 8eed1f1f47..b61504ccdd 100644 --- a/.agents/skills/open-pr2/SKILL.md +++ b/.agents/skills/open-pr2/SKILL.md @@ -1,6 +1,6 @@ --- name: open-pr2 -description: Open or update a pull request on pascalorg/editor with a plain-language issue-and-fix description based on the full branch diff. Use only when the user explicitly asks for OpenPR2 or /open-pr2. +description: Open or update a pascalorg/editor pull request from verified branch-authored commits while excluding changes imported by merges. Use only when the user explicitly asks for OpenPR2 or /open-pr2. metadata: internal: true disable-model-invocation: true @@ -9,148 +9,248 @@ allowed-tools: Bash(git *) Bash(gh *) Bash(bun *) Read # OpenPR2 -Open or update a pull request against `pascalorg/editor` from the current branch. Keep the repository's PR template, but write the body like one developer explaining the change to another. +Open or update a pull request against `pascalorg/editor`. Describe only work introduced by the feature branch's own non-merge, first-parent commits and still present in the PR. Treat merge commits as synchronization, never as evidence that the feature branch implemented the merged work. -## 1. Pre-flight +The invariant is: -Inspect the working tree and the whole branch before writing anything: +> Every title or body claim must map to an eligible branch commit, concrete changed lines from that commit, and the final PR diff. + +If provenance cannot be established, stop before changing GitHub and explain why the branch must be cleaned or the intended commits identified. + +## 1. Establish the canonical base + +Inspect the repository, working tree, remotes, branch, and existing PR: ```bash git status git branch --show-current -git log --oneline main..HEAD -git diff --stat main...HEAD -git diff --name-status main...HEAD +git remote -v +gh pr view --json number,url,title,body,baseRefName,headRefName 2>/dev/null ``` -Read the relevant parts of `git diff main...HEAD`. Do not build the description from the latest commit alone or from conversation memory. +For this repository, the canonical base is the remote whose fetch URL is `pascalorg/editor`, normally `upstream`. Use the existing PR's `baseRefName`; use `main` only when no PR exists. Fetch that exact base before comparing: + +```bash +git fetch upstream +git rev-parse HEAD upstream/ +git merge-base --all upstream/ HEAD +``` Stop if: -- The current branch is `main`. Ask the user to create a feature branch first. -- The branch has no commits ahead of `main`. -- There are uncommitted changes the user has not asked to commit. +- the current branch is the base branch; +- the working tree has changes the user did not ask to commit; +- the canonical remote or base cannot be resolved; +- the branch has no eligible commits after the provenance audit below; +- there is more than one merge base, unless the ambiguity is resolved before continuing. + +Completion criterion: one fetched canonical base ref and one unambiguous merge base are recorded. A local branch named `main` is not a substitute for the canonical ref. + +## 2. Build the provenance audit -For a non-trivial change, run checks that match the affected packages. Prefer focused tests plus: +Run the deterministic audit. Save its output as the provenance manifest for the rest of the workflow: ```bash -bun run check-types -bun run build +bun .agents/skills/open-pr2/scripts/provenance-audit.ts \ + --base upstream/ \ + --head HEAD \ + --format markdown ``` -Do not open a PR when a required check fails. Report the failure instead. Do not claim that a command or manual test passed unless it was run. +The audit classifies: -## 2. Read the current PR template +- **Eligible:** non-merge commits on the first-parent feature-branch line. +- **Patch-equivalent:** branch commits whose patch already exists on the canonical base. +- **Imported:** merge commits and changes reachable only through their merged parents. +- **Branch-only files:** final-diff files touched only by eligible commits. +- **Merge-only files:** final-diff files touched only by excluded merges. +- **Patch-equivalent files:** final-diff files whose eligible-looking patch already exists on the base. +- **Overlapping files:** files touched by both an eligible commit and a merge; these require hunk review. +- **Unexplained files:** final-diff files not accounted for by either class. +- **Reverted eligible files:** files touched by eligible commits but absent from the final diff. -Read `.github/pull_request_template.md` every time. Its headings and checklist wording are the source of truth. +Treat merge-only files, patch-equivalent commits and files, and reverted eligible files as excluded. Stop for unexplained files. For every overlapping file, compare the eligible commit patch with the merge patch before deciding whether any behavior is describable: -Keep the template headings in the same order: +```bash +git diff ^1 -- +git diff ^ -- +``` -1. `## What does this PR do?` -2. `## How to test` -3. `## Screenshots / screen recording` -4. `## Checklist` +The merge patch is an exclusion inventory. Conflict resolution inside a merge is maintenance unless the user identifies it as intentional feature work. -Do not replace them with `Summary`, `Details`, `Validation`, or custom headings unless the template itself changes. +For every eligible commit, inspect the actual patch relative to its first parent: -## 3. Write the title +```bash +git show --format=fuller --stat --name-status +git diff ^ -- +``` -- Keep it under 70 characters when practical. -- State the result, not the activity. Prefer `fix(editor): keep curved room slabs attached` over `update wall files`. -- Add a package scope when one package clearly owns the change, such as `core:`, `viewer:`, `editor:`, or `mcp:`. -- Avoid vague verbs such as `improve`, `enhance`, `update`, or `refactor` when a concrete verb fits. +Commit subjects, branch names, prior chat, issue text, and the final aggregate diff are discovery hints only. They are not evidence of authorship. -## 4. Write the body in plain language +The script uses `git cherry` to detect patch-equivalent commits. This cannot detect rewritten or partially copied patches. If a non-merge commit bundles unrelated feature families, has a subject that does not explain major parts of its patch, or appears copied from another branch, classify it as ambiguous. Stop and ask the user to identify the intended hunks or recommend rebuilding from `upstream/`. -### What does this PR do? +Completion criterion: the manifest is generated, every overlap has a hunk decision, and there are no unexplained or unresolved ambiguous changes. -The reviewer should understand every changed behavior without opening the diff. Do not compress unrelated fixes into a paragraph or a long bullet. +## 3. Intersect authored work with the final PR -Give each problem its own short item. Use this exact shape: +Inspect what reviewers will actually receive: -```markdown -- **Short feature or problem name** - - Issue: One short sentence describing what was wrong or missing. - - Fixed: One short sentence describing the behavior after this PR. +```bash +git diff --stat upstream/...HEAD +git diff --name-status upstream/...HEAD +git diff upstream/...HEAD -- ``` -Add one more indented sentence only when the reviewer needs an important constraint, risk, or design decision. Keep it short and do not add labels such as `Details`, `Technical`, or `Implementation`. +For an existing PR, also inspect GitHub's diff: -Example: - -```markdown -- **Curved triangular rooms** - - Issue: Slabs and ceilings kept a straight corner after curving a wall. - - Fixed: Both surfaces now rebuild from the curved room boundary. - -- **Wall and fence thickness** - - Issue: Thickness could only be changed from the settings panel. - - Fixed: Each face now has a circular thickness handle in 2D and 3D. - - The centerline stays fixed, and the change uses one undo step. +```bash +gh pr diff --name-only +gh pr diff ``` -Keep the item title concrete. Start with product behavior, not filenames or function names. Cover every meaningful user-visible fix on the branch. Combine items only when they describe the same problem and the same fix. +A change is describable only when both are true: + +1. an eligible commit's patch proves the branch introduced it; and +2. the behavior still appears in the final PR diff. + +This intersection prevents two opposite errors: + +- merged work appearing in the description merely because it is visible in the aggregate branch history; +- reverted or superseded branch work appearing merely because an old eligible commit mentions it. + +Aggregate-diff changes with no eligible-commit evidence are inherited or unexplained. Exclude them from PR claims and report them to the user outside the proposed PR body. If those unexplained changes make the PR unsafe or misleading to review, stop and recommend branch cleanup. + +Completion criterion: every surviving eligible behavior is identified, and every unexplained final-diff cluster is excluded and reported. + +## 4. Build a claim ledger + +Before writing prose, create a private evidence ledger with one row per behavior: + +| Claim | Eligible commit | Patch evidence | Present in final PR | Test evidence | +|---|---|---|---|---| +| Concrete user-visible result | SHA | Files and relevant hunks | Yes | Command/manual check or not run | + +Rules: + +- Every proposed title and `What does this PR do?` item must have a ledger row. +- Every meaningful surviving eligible behavior must be represented. +- A filename alone is insufficient; inspect the relevant hunk. +- Tests added by a merge do not validate branch-authored claims. +- Never infer a claim from a branch name such as `improve-project-items`. +- Never turn an unexplained aggregate-diff cluster into an umbrella claim. + +Completion criterion: the body can be reconstructed from the ledger without using conversation memory or merge-imported changes. + +### Reconcile a user-provided example + +When the user supplies an expected title, description, feature list, or teammate review, compare every supplied item with the ledger before drafting: + +| Supplied item | Eligible commit | Final evidence | Decision | +|---|---|---|---| +| User's wording | SHA or none | Files and hunks | Include, rewrite, exclude, or clarify | + +Preserve the supplied level of technical detail when its claims are supported. The repository template still controls section headings. Report every omitted or rewritten item and the evidence decision. User wording is a coverage target, not provenance evidence. + +## 5. Run proportionate checks -Avoid this compressed style: +Choose focused tests from the eligible changed behavior, then run package-level checks appropriate to that scope. For a non-trivial cross-package change, prefer focused tests plus: -```text -This PR fixes curved wall topology, adds thickness handles, improves floor-plan previews, updates roof paint slots, and cleans up roof controls. +```bash +bun run check-types +bun run build ``` -Link issues with `Fixes #123` or `Refs #123` when applicable. Never invent an issue number. +Do not open or update the PR when a required check fails. Report the failure. Record only commands actually run and their real result; leave manual-runtime and checklist claims unchecked when they were not verified. -### How to test +Completion criterion: each claimed check has command output from this run. + +## 6. Read the current PR template -Write numbered reviewer steps. Put the action on the numbered line and the expected result on a short indented line. +Read `.github/pull_request_template.md` every time. Keep its headings, order, and checklist wording exactly. The template is the source of truth. -Good: +Preserve from an existing PR: -```text -1. Create a triangular room and curve one wall. - - The slab and ceiling should follow the curved corner with no gap. +- `Fixes #...` and `Refs #...` lines; +- screenshots, recordings, links, and embedded media; +- relevant reviewer notes; +- checklist state only where current evidence still supports it. -2. Drag either wall thickness dot. - - The wall should stay centered while its thickness changes. +Remove stale claims and test steps that lack a claim-ledger row. Never preserve inaccurate prose merely because it already exists. + +Treat generated blocks such as ` ... ` as automation output, not author evidence. Preserve a generated block only when all of its claims pass the ledger audit and repository automation expects it to remain. Otherwise remove it from the authored body and report that decision. Never copy claims from a generated block into the human description without independent commit-and-hunk evidence. + +## 7. Write from the ledger + +### Title + +- Describe the most important surviving eligible result. +- Keep it under 70 characters when practical. +- Use a package scope when one package clearly owns the change. +- Prefer a concrete result over `improve`, `enhance`, `update`, or `refactor`. +- When eligible work has unrelated feature groups, stop and recommend splitting the PR instead of inventing a narrow umbrella title. +- Update an existing title when its scope is unsupported by the ledger. + +### What does this PR do? + +Use one short item per problem and result: + +```markdown +- **Short feature or problem name** + - Issue: One short sentence describing what was wrong or missing. + - Fixed: One short sentence describing the behavior introduced by an eligible commit. ``` -List automated commands only when they were run. Include pass counts when they are known and useful. Do not turn the section into a dump of every command used during development. +Add one short constraint or design sentence only when a reviewer needs it. Cover all meaningful ledger rows and nothing else. Link issues only when an issue number is known. + +### How to test + +Write numbered, concrete reviewer steps for ledger-backed behavior. Put the expected result on an indented line. Include automated commands only when they were run during this workflow. ### Screenshots / screen recording -- Preserve any existing media verbatim when updating a PR. -- For a visual or interactive change, add the supplied media. If none exists, write `Not added yet.` -- For a non-visual change, write `N/A, no visual change.` -- Do not claim that a recording exists when it does not. +Preserve existing media verbatim. For visual changes without supplied media, write `Not added yet.` For non-visual changes, write `N/A, no visual change.` ### Checklist -Copy every checklist line from the current template verbatim. +Copy the current template verbatim. Tick only evidence-backed items. A prior checked box may be unchecked when the current audit cannot verify it. + +### Human writing pass + +Read the draft as a developer who has not seen the branch: + +- use plain words, short sentences, and active voice; +- preserve necessary technical detail without turning the body into a file inventory; +- remove filler, hype, repeated claims, and implementation trivia that does not help review; +- keep unrelated ledger rows separate rather than compressing them into a vague umbrella item; +- make every test step concrete and verifiable; +- match a user-provided example's useful specificity while keeping only evidence-backed claims. + +Completion criterion: the draft reads naturally and every sentence still maps to the ledger. -- Tick an item only when it is true. -- `bun dev` is checked only after local runtime testing. -- The code-style item is checked only after the requested style command passes. -- Documentation is checked when docs were updated or when the item explicitly says it is not applicable. Otherwise leave it unchecked. -- Confirm the actual base branch before checking the target-branch item. +## 8. Pre-write audit -## 5. Human writing pass +Read the proposed title and body against the claim ledger and exclusion inventory. -Before submitting, read the title and body once as a reviewer who has not seen the branch. +For every title phrase, feature bullet, test step, and checked box, answer: -Rewrite anything that fails these checks: +1. Which eligible non-merge commit introduced it? +2. Which changed lines prove it? +3. Is it still present in the final PR diff? +4. Was the claimed validation actually run? -- Use plain words and short sentences. -- Say what the change does. Avoid phrases that could describe any PR. -- Remove filler, hype, sales language, and chatbot phrases. -- Remove repeated points and details that the diff explains on its own. -- Avoid jargon unless the repository uses the term and the reviewer needs it. -- Avoid forced lists, excessive bold text, em dashes, and long parenthetical asides. -- Prefer active voice. -- Keep a human rhythm. The body should not read like generated release notes. -- Make every test step concrete and verifiable. +Then confirm: -If the summary sounds too small, add the missing problem or behavior. If it sounds dense, remove implementation trivia before shortening the explanation of the bug. +- no merge-only feature is named; +- no meaningful surviving eligible behavior is omitted; +- no branch-name or conversation assumption became a claim; +- unexplained final-diff changes were reported to the user rather than described as authored work; +- the title matches the ledger rather than the aggregate diff; +- each user-supplied example item has an explicit include, rewrite, exclude, or clarify decision; +- generated summary blocks contain no unaudited claims. -## 6. Push and find the PR +Any unanswered question blocks the GitHub update. + +## 9. Push, create or update, and verify Push the current branch: @@ -158,59 +258,38 @@ Push the current branch: git push -u origin HEAD ``` -Check whether it already has a PR: +Create a PR only when none exists. Otherwise update the existing PR; never create a duplicate. Pass Markdown through a quoted heredoc. ```bash -gh pr view --json number,url,title,body 2>/dev/null +gh pr create --title "" --body "$(cat <<'EOF' + +EOF +)" ``` -### No existing PR - -Create one with `gh pr create`. Pass the body through a quoted heredoc so Markdown stays intact: - ```bash -gh pr create --title "" --body "$(cat <<'EOF' -<body using the current PR template> +gh pr edit <number> --title "<ledger-backed title>" --body "$(cat <<'EOF' +<body using the current template> EOF )" ``` -### Existing PR - -Do not create another PR. Update the current one from the full branch diff. - -Before rewriting it: +Read it back: ```bash -gh pr view --json number,title,body,url -git log --oneline main..HEAD -git diff --stat main...HEAD +gh pr view <number> --json number,url,title,body,baseRefName,headRefName ``` -When rebuilding the body: - -- Preserve `Fixes #123` and `Refs #123` lines. -- Preserve screenshots, recordings, links, and embedded images verbatim unless the user supplied replacements. -- Preserve the user's checklist state for work that remains true. Never change an unchecked item to checked without evidence. -- Keep extra reviewer notes that are still relevant. -- Remove old claims and test steps that no longer match the branch. -- Leave the title unchanged unless the branch's purpose clearly changed. - -Apply the update with `gh pr edit <number> --body ...`. Change the title only when needed. - -## 7. Verify and report - -Read the PR back after creation or editing: - -```bash -gh pr view --json number,url,title,body,baseRefName,headRefName -``` +Verify the saved title, template sections, base, head, media, checklist state, and every claim against the ledger. Correct a serialization mistake immediately; stop for any provenance or content discrepancy. -Confirm that the title, template sections, base branch, and body were saved correctly. +## 10. Report Return: -- PR URL -- Title -- Checks and tests actually run -- Any unchecked checklist item or missing recording the reviewer should know about +- PR URL and final title; +- eligible commits used as evidence; +- merge commits excluded from the description; +- checks actually run and their outcomes; +- unexplained or inherited final-diff changes; +- unchecked checklist items or missing recordings; +- whether branch cleanup or PR splitting is still recommended. diff --git a/.agents/skills/open-pr2/scripts/provenance-audit.test.ts b/.agents/skills/open-pr2/scripts/provenance-audit.test.ts new file mode 100644 index 0000000000..7af702e2e3 --- /dev/null +++ b/.agents/skills/open-pr2/scripts/provenance-audit.test.ts @@ -0,0 +1,129 @@ +import { afterEach, describe, expect, test } from 'bun:test' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { auditRepository, formatAuditMarkdown } from './provenance-audit' + +const repositories: string[] = [] + +function git(repository: string, ...args: string[]): string { + const result = Bun.spawnSync({ + cmd: ['git', '-C', repository, ...args], + stdout: 'pipe', + stderr: 'pipe', + }) + if (result.exitCode !== 0) { + throw new Error(result.stderr.toString().trim() || `git ${args.join(' ')} failed`) + } + return result.stdout.toString().trim() +} + +function repository(): string { + const path = mkdtempSync(join(tmpdir(), 'open-pr2-provenance-')) + repositories.push(path) + git(path, 'init', '-b', 'main') + git(path, 'config', 'user.name', 'OpenPR2 Test') + git(path, 'config', 'user.email', 'open-pr2@example.com') + writeFileSync(join(path, 'base.txt'), 'base\n') + git(path, 'add', 'base.txt') + git(path, 'commit', '-m', 'base') + return path +} + +function commitFile(repository: string, path: string, content: string, subject: string): string { + writeFileSync(join(repository, path), content) + git(repository, 'add', path) + git(repository, 'commit', '-m', subject) + return git(repository, 'rev-parse', 'HEAD') +} + +afterEach(() => { + for (const path of repositories.splice(0)) { + rmSync(path, { recursive: true, force: true }) + } +}) + +describe('OpenPR2 provenance audit', () => { + test('keeps first-parent commits and excludes files imported by a merge', () => { + const path = repository() + git(path, 'checkout', '-b', 'feature') + const beforeMerge = commitFile(path, 'feature-before.txt', 'before\n', 'feature before merge') + + git(path, 'checkout', '-b', 'other', 'main') + commitFile(path, 'imported.txt', 'imported\n', 'other branch feature') + + git(path, 'checkout', 'feature') + git(path, 'merge', '--no-ff', 'other', '-m', 'merge other branch') + const afterMerge = commitFile(path, 'feature-after.txt', 'after\n', 'feature after merge') + + const audit = auditRepository({ repository: path, baseRef: 'main', headRef: 'feature' }) + + expect(audit.eligibleCommits.map((commit) => commit.sha)).toEqual([beforeMerge, afterMerge]) + expect(audit.excludedMerges).toHaveLength(1) + expect(audit.branchOnlyFiles).toEqual(['feature-after.txt', 'feature-before.txt']) + expect(audit.mergeOnlyFiles).toEqual(['imported.txt']) + expect(audit.overlappingFiles).toEqual([]) + expect(audit.unexplainedFiles).toEqual([]) + }) + + test('marks files changed by both a merge and a later branch commit as overlapping', () => { + const path = repository() + git(path, 'checkout', '-b', 'feature') + git(path, 'checkout', '-b', 'other', 'main') + commitFile(path, 'shared.txt', 'imported\n', 'other branch feature') + + git(path, 'checkout', 'feature') + git(path, 'merge', '--no-ff', 'other', '-m', 'merge other branch') + commitFile(path, 'shared.txt', 'imported\nfeature adjustment\n', 'adjust imported feature') + + const audit = auditRepository({ repository: path, baseRef: 'main', headRef: 'feature' }) + + expect(audit.branchOnlyFiles).toEqual([]) + expect(audit.mergeOnlyFiles).toEqual([]) + expect(audit.overlappingFiles).toEqual(['shared.txt']) + }) + + test('does not describe eligible changes that were later reverted', () => { + const path = repository() + git(path, 'checkout', '-b', 'feature') + commitFile(path, 'temporary.txt', 'temporary\n', 'add temporary feature') + git(path, 'rm', 'temporary.txt') + git(path, 'commit', '-m', 'remove temporary feature') + + const audit = auditRepository({ repository: path, baseRef: 'main', headRef: 'feature' }) + + expect(audit.finalFiles).toEqual([]) + expect(audit.revertedEligibleFiles).toEqual(['temporary.txt']) + }) + + test('excludes a branch commit whose patch is already present on the base', () => { + const path = repository() + git(path, 'checkout', '-b', 'feature') + const featureCommit = commitFile(path, 'same.txt', 'same\n', 'add shared change') + + git(path, 'checkout', 'main') + commitFile(path, 'same.txt', 'same\n', 'land shared change upstream') + + const audit = auditRepository({ repository: path, baseRef: 'main', headRef: 'feature' }) + + expect(audit.eligibleCommits).toEqual([]) + expect(audit.patchEquivalentCommits.map((commit) => commit.sha)).toEqual([featureCommit]) + expect(audit.finalFiles).toEqual(['same.txt']) + expect(audit.patchEquivalentFiles).toEqual(['same.txt']) + expect(audit.unexplainedFiles).toEqual([]) + }) + + test('formats all provenance classifications for the agent audit', () => { + const path = repository() + git(path, 'checkout', '-b', 'feature') + commitFile(path, 'feature.txt', 'feature\n', 'add feature') + const markdown = formatAuditMarkdown( + auditRepository({ repository: path, baseRef: 'main', headRef: 'feature' }), + ) + + expect(markdown).toContain('## Eligible first-parent commits') + expect(markdown).toContain('## Merge commits excluded') + expect(markdown).toContain('## Branch-only final files') + expect(markdown).toContain('feature.txt') + }) +}) diff --git a/.agents/skills/open-pr2/scripts/provenance-audit.ts b/.agents/skills/open-pr2/scripts/provenance-audit.ts new file mode 100644 index 0000000000..de22f90b82 --- /dev/null +++ b/.agents/skills/open-pr2/scripts/provenance-audit.ts @@ -0,0 +1,241 @@ +export type CommitAudit = { + sha: string + shortSha: string + subject: string + parents: string[] + changedFiles: string[] + patchEquivalent: boolean +} + +export type ProvenanceAudit = { + repository: string + baseRef: string + headRef: string + mergeBase: string + eligibleCommits: CommitAudit[] + patchEquivalentCommits: CommitAudit[] + excludedMerges: CommitAudit[] + finalFiles: string[] + branchOnlyFiles: string[] + mergeOnlyFiles: string[] + patchEquivalentFiles: string[] + overlappingFiles: string[] + unexplainedFiles: string[] + revertedEligibleFiles: string[] +} + +type AuditOptions = { + repository: string + baseRef: string + headRef?: string +} + +function runGit(repository: string, args: string[]): string { + const result = Bun.spawnSync({ + cmd: ['git', '-C', repository, ...args], + stdout: 'pipe', + stderr: 'pipe', + }) + const stdout = result.stdout.toString() + if (result.exitCode !== 0) { + const stderr = result.stderr.toString().trim() + throw new Error(`git ${args.join(' ')} failed${stderr ? `: ${stderr}` : ''}`) + } + return stdout +} + +function lines(value: string): string[] { + return value + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) +} + +function nulSeparated(value: string): string[] { + return value.split('\0').filter(Boolean).sort() +} + +function changedFiles(repository: string, from: string | null, to: string): string[] { + if (!from) { + return nulSeparated( + runGit(repository, ['diff-tree', '--root', '--no-commit-id', '--name-only', '-r', '-z', to]), + ) + } + return nulSeparated(runGit(repository, ['diff', '--name-only', '-z', from, to])) +} + +function intersection(left: Set<string>, right: Set<string>): string[] { + return [...left].filter((value) => right.has(value)).sort() +} + +function difference(left: Set<string>, right: Set<string>): string[] { + return [...left].filter((value) => !right.has(value)).sort() +} + +function commitAudit( + repository: string, + sha: string, + patchEquivalentShas: Set<string>, +): CommitAudit { + const parents = lines(runGit(repository, ['show', '-s', '--format=%P', sha]).trim().replaceAll(' ', '\n')) + const subject = runGit(repository, ['show', '-s', '--format=%s', sha]).trim() + return { + sha, + shortSha: sha.slice(0, 10), + subject, + parents, + changedFiles: changedFiles(repository, parents[0] ?? null, sha), + patchEquivalent: patchEquivalentShas.has(sha), + } +} + +export function auditRepository(options: AuditOptions): ProvenanceAudit { + const { repository, baseRef, headRef = 'HEAD' } = options + runGit(repository, ['rev-parse', '--verify', baseRef]) + runGit(repository, ['rev-parse', '--verify', headRef]) + + const mergeBases = lines(runGit(repository, ['merge-base', '--all', baseRef, headRef])) + if (mergeBases.length !== 1) { + throw new Error( + `expected exactly one merge base for ${baseRef} and ${headRef}; found ${mergeBases.length}`, + ) + } + + const patchEquivalentShas = new Set( + lines(runGit(repository, ['cherry', baseRef, headRef])) + .filter((line) => line.startsWith('- ')) + .map((line) => line.slice(2).split(' ')[0]!) + .filter(Boolean), + ) + const firstParentShas = lines( + runGit(repository, ['rev-list', '--first-parent', '--reverse', `${baseRef}..${headRef}`]), + ) + const commits = firstParentShas.map((sha) => commitAudit(repository, sha, patchEquivalentShas)) + const excludedMerges = commits.filter((commit) => commit.parents.length > 1) + const branchCommits = commits.filter((commit) => commit.parents.length <= 1) + const patchEquivalentCommits = branchCommits.filter((commit) => commit.patchEquivalent) + const eligibleCommits = branchCommits.filter((commit) => !commit.patchEquivalent) + + const finalFiles = nulSeparated( + runGit(repository, ['diff', '--name-only', '-z', `${baseRef}...${headRef}`]), + ) + const finalSet = new Set(finalFiles) + const eligibleSet = new Set(eligibleCommits.flatMap((commit) => commit.changedFiles)) + const patchEquivalentSet = new Set( + patchEquivalentCommits.flatMap((commit) => commit.changedFiles), + ) + const importedSet = new Set(excludedMerges.flatMap((commit) => commit.changedFiles)) + const branchCandidates = new Set(intersection(finalSet, eligibleSet)) + const importCandidates = new Set(intersection(finalSet, importedSet)) + + return { + repository, + baseRef, + headRef, + mergeBase: mergeBases[0]!, + eligibleCommits, + patchEquivalentCommits, + excludedMerges, + finalFiles, + branchOnlyFiles: difference(branchCandidates, importedSet), + mergeOnlyFiles: difference(importCandidates, eligibleSet), + patchEquivalentFiles: intersection(finalSet, patchEquivalentSet), + overlappingFiles: intersection(branchCandidates, importedSet), + unexplainedFiles: difference( + difference(difference(finalSet, eligibleSet), importedSet), + patchEquivalentSet, + ), + revertedEligibleFiles: difference(eligibleSet, finalSet), + } +} + +function markdownList(values: string[]): string { + return values.length === 0 ? '- None' : values.map((value) => `- ${value}`).join('\n') +} + +function commitTable(commits: CommitAudit[]): string { + if (commits.length === 0) return '_None_' + return [ + '| Commit | Subject | Files |', + '|---|---|---:|', + ...commits.map( + (commit) => + `| \`${commit.shortSha}\` | ${commit.subject.replaceAll('|', '\\|')} | ${commit.changedFiles.length} |`, + ), + ].join('\n') +} + +export function formatAuditMarkdown(audit: ProvenanceAudit): string { + return [ + '# OpenPR2 provenance audit', + '', + `- Base: \`${audit.baseRef}\``, + `- Head: \`${audit.headRef}\``, + `- Merge base: \`${audit.mergeBase}\``, + '', + '## Eligible first-parent commits', + '', + commitTable(audit.eligibleCommits), + '', + '## Patch-equivalent commits excluded', + '', + commitTable(audit.patchEquivalentCommits), + '', + '## Merge commits excluded', + '', + commitTable(audit.excludedMerges), + '', + '## Branch-only final files', + '', + markdownList(audit.branchOnlyFiles), + '', + '## Merge-only final files', + '', + markdownList(audit.mergeOnlyFiles), + '', + '## Patch-equivalent final files', + '', + markdownList(audit.patchEquivalentFiles), + '', + '## Overlapping files requiring hunk review', + '', + markdownList(audit.overlappingFiles), + '', + '## Unexplained final files', + '', + markdownList(audit.unexplainedFiles), + '', + '## Eligible files absent from the final diff', + '', + markdownList(audit.revertedEligibleFiles), + ].join('\n') +} + +function readArgument(name: string): string | undefined { + const index = Bun.argv.indexOf(name) + return index >= 0 ? Bun.argv[index + 1] : undefined +} + +if (import.meta.main) { + const baseRef = readArgument('--base') + const headRef = readArgument('--head') ?? 'HEAD' + const repository = readArgument('--repo') ?? process.cwd() + const format = readArgument('--format') ?? 'markdown' + + if (!baseRef || !['json', 'markdown'].includes(format)) { + console.error( + 'Usage: bun provenance-audit.ts --base <canonical-base-ref> [--head HEAD] [--repo path] [--format markdown|json]', + ) + process.exit(1) + } + + try { + const audit = auditRepository({ repository, baseRef, headRef }) + process.stdout.write( + format === 'json' ? `${JSON.stringify(audit, null, 2)}\n` : `${formatAuditMarkdown(audit)}\n`, + ) + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)) + process.exit(2) + } +} diff --git a/.gitignore b/.gitignore index e26d858f69..34a9ed2d91 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,7 @@ supabase/.temp/ # Build Outputs .next/ +.next-failed-cache/ out/ build dist diff --git a/apps/editor/lib/bootstrap.ts b/apps/editor/lib/bootstrap.ts index fbebec2d12..16335bbf78 100644 --- a/apps/editor/lib/bootstrap.ts +++ b/apps/editor/lib/bootstrap.ts @@ -112,6 +112,5 @@ registerEditorHostPanel({ ...streetscapeHostPanel, creator: { name: 'Sudhir Yadav', url: 'https://github.com/sudhir9297' }, }) - loadBuiltinsSync() void loadExternalPlugins() diff --git a/apps/editor/next.config.ts b/apps/editor/next.config.ts index 44cffefdbc..ee4414e61f 100644 --- a/apps/editor/next.config.ts +++ b/apps/editor/next.config.ts @@ -42,10 +42,14 @@ const nextConfig: NextConfig = { ], turbopack: { resolveAlias: { - react: './node_modules/react', - three: './node_modules/three', - '@react-three/fiber': './node_modules/@react-three/fiber', - '@react-three/drei': './node_modules/@react-three/drei', + 'react-dom': '../../node_modules/react-dom', + '@pascal-app/core': '../../packages/core', + '@pascal-app/viewer': '../../packages/viewer', + '@pascal-app/editor': '../../packages/editor', + react: '../../node_modules/react', + three: '../../node_modules/three', + '@react-three/fiber': '../../node_modules/@react-three/fiber', + '@react-three/drei': '../../node_modules/@react-three/drei', }, }, experimental: { diff --git a/apps/editor/package.json b/apps/editor/package.json index 8132f573f0..f79e756e6f 100644 --- a/apps/editor/package.json +++ b/apps/editor/package.json @@ -20,7 +20,7 @@ "@pascal-app/mcp": "*", "@pascal-app/nodes": "*", "@pascal-app/plugin-bones": "github:pascalorg/plugin-bones#5679260261ee1c733656ff6dfb99e30bb24b58a7", - "@pascal-app/plugin-environment": "github:AxiomeCG/environment#1aa0bc3cbed273dbf751e9af79e446f6de105c90", + "@pascal-app/plugin-environment": "github:AxiomeCG/environment#40baf63ddd06a657aaa0f60e1559fd9ad561f295", "@pascal-app/plugin-pool": "github:sudhir9297/pool-pascal-plugin#483a080558293cd344170e66318230e2ac9b6ae8", "@pascal-app/plugin-streetscape": "github:sudhir9297/streetscape-pascal-plugin#1c04ec9ccb3fa8124ec56dfc1026567cbbc51aef", "@pascal-app/plugin-trees": "github:pascalorg/plugin-trees#56d978cd9b409b716207b3f3d269455d3cd6f067", diff --git a/bun.lock b/bun.lock index 30e10f30bd..4fb2f5c6e9 100644 --- a/bun.lock +++ b/bun.lock @@ -36,7 +36,7 @@ "@pascal-app/mcp": "*", "@pascal-app/nodes": "*", "@pascal-app/plugin-bones": "github:pascalorg/plugin-bones#5679260261ee1c733656ff6dfb99e30bb24b58a7", - "@pascal-app/plugin-environment": "github:AxiomeCG/environment#1aa0bc3cbed273dbf751e9af79e446f6de105c90", + "@pascal-app/plugin-environment": "github:AxiomeCG/environment#40baf63ddd06a657aaa0f60e1559fd9ad561f295", "@pascal-app/plugin-pool": "github:sudhir9297/pool-pascal-plugin#483a080558293cd344170e66318230e2ac9b6ae8", "@pascal-app/plugin-streetscape": "github:sudhir9297/streetscape-pascal-plugin#1c04ec9ccb3fa8124ec56dfc1026567cbbc51aef", "@pascal-app/plugin-trees": "github:pascalorg/plugin-trees#56d978cd9b409b716207b3f3d269455d3cd6f067", @@ -752,7 +752,7 @@ "@pascal-app/plugin-bones": ["@pascal-app/plugin-bones@github:pascalorg/plugin-bones#5679260", { "peerDependencies": { "@pascal-app/core": ">=0.9.1 <1", "@pascal-app/editor": ">=0.9.1 <1", "@pascal-app/viewer": ">=0.9.1 <1", "@react-three/fiber": "^9", "react": "^18 || ^19", "three": "^0.185", "zod": "^4", "zustand": "^5" } }, "pascalorg-plugin-bones-5679260", "sha512-uQkyHHOl/VuYx2+d/MmcJui/KEAQZ2UUnO4ywp1XaopmD2YfN+Yx/fR/lS/VL2joEoyyZeBLC3KaXK1Sx5qEvg=="], - "@pascal-app/plugin-environment": ["@pascal-app/plugin-environment@github:AxiomeCG/environment#1aa0bc3", { "peerDependencies": { "@dgreenheck/ez-tree": "^1.1.0", "@pascal-app/core": ">=1.0.0-beta.6 <2", "@pascal-app/editor": ">=1.0.0-beta.6 <2", "@pascal-app/viewer": ">=1.0.0-beta.6 <2", "@radix-ui/react-tooltip": "^1.2.8", "@react-three/fiber": "^9", "lucide-react": "^1.7.0", "react": "^18 || ^19", "react-colorful": "^5.8.1", "three": "^0.186", "zod": "^4", "zustand": "^5" } }, "AxiomeCG-environment-1aa0bc3", "sha512-wvCYDEr62ItSwgWf8zOJL0z97zTyZ7TszrFwLOcIhX7LRYrmHhP/6LE7lB6lBBeXNPExg7jRk8eQZm+ri6GfaQ=="], + "@pascal-app/plugin-environment": ["@pascal-app/plugin-environment@github:AxiomeCG/environment#40baf63", { "peerDependencies": { "@dgreenheck/ez-tree": "^1.1.0", "@pascal-app/core": ">=1.0.0-beta.6 <2", "@pascal-app/editor": ">=1.0.0-beta.6 <2", "@pascal-app/viewer": ">=1.0.0-beta.6 <2", "@radix-ui/react-tooltip": "^1.2.8", "@react-three/fiber": "^9", "lucide-react": "^1.7.0", "react": "^18 || ^19", "react-colorful": "^5.8.1", "three": "^0.185", "zod": "^4", "zustand": "^5" } }, "AxiomeCG-environment-40baf63", "sha512-mb9i4IFq1c62BcAhtGJAKgJHBVQtFYySFW/LzkmnNfnWSOlGdUavoiR4f8cq5Y4xg6MTvGFX0ytO1Urk8wOPPQ=="], "@pascal-app/plugin-pool": ["@pascal-app/plugin-pool@github:sudhir9297/pool-pascal-plugin#483a080", { "dependencies": { "three-bvh-csg": "^0.0.18", "three-mesh-bvh": "^0.9.8" }, "peerDependencies": { "@pascal-app/core": ">=0.9.1 <1 || >=1.0.0-beta.0 <2", "@pascal-app/editor": ">=0.9.1 <1 || >=1.0.0-beta.0 <2", "@pascal-app/viewer": ">=0.9.1 <1 || >=1.0.0-beta.0 <2", "@react-three/fiber": "^9", "react": "^18 || ^19", "three": "^0.186.0", "zod": "^4", "zustand": "^5" } }, "sudhir9297-pool-pascal-plugin-483a080", "sha512-amY0DNHKXnjBGLyouD78sIKZ+AbQrX67gQEX2N21I4t9o1F07sObYnU77tLsCTc8TQdm/G7YNSWM1w1sEmwhVw=="], @@ -1596,7 +1596,7 @@ "lru-cache": ["lru-cache@11.5.1", "", {}, "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A=="], - "lucide-react": ["lucide-react@1.17.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-9FA9evdox/JQL5PT57fdA1x/yg8T7knJ98+zjTL3UfKza6pflQUUh3XtaQIHKvnsJw1lmsEyHVlt5jchYxOQ5w=="], + "lucide-react": ["lucide-react@1.46.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Bv+FZXgZPrxc/NCl1e7JJVQFLdiCxYgxNVhqoV7X0p6I8ADJo8DxBnK1auH0fZz4AmqOJ3jgneL4f1i8LJQRAA=="], "maath": ["maath@0.10.8", "", { "peerDependencies": { "@types/three": ">=0.134.0", "three": ">=0.134.0" } }, "sha512-tRvbDF0Pgqz+9XUa4jjfgAQ8/aPKmQdWXilFu2tMy4GWj4NOsx99HlULO4IeREfbO3a0sA145DZYyvXPkybm0g=="], diff --git a/package.json b/package.json index fcea0ccdde..92c7f1ef80 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "check:fix": "biome check --write", "check-types": "turbo run check-types", "test": "turbo run test", - "skills:validate": "bun scripts/validate-skills.ts && bun test scripts/cursor-plugin-policy.test.ts scripts/clawhub-ignore-policy.test.ts scripts/claude-mcp-config-policy.test.ts scripts/openai-tool-annotation-policy.test.ts scripts/path-containment.test.ts scripts/public-skill-discovery-policy.test.ts", + "skills:validate": "bun scripts/validate-skills.ts && bun test ./.agents/skills/open-pr2/scripts/provenance-audit.test.ts scripts/cursor-plugin-policy.test.ts scripts/clawhub-ignore-policy.test.ts scripts/claude-mcp-config-policy.test.ts scripts/openai-tool-annotation-policy.test.ts scripts/path-containment.test.ts scripts/public-skill-discovery-policy.test.ts", "kill": "lsof -ti:3002 | xargs kill -9 2>/dev/null || echo 'No processes found on port 3002'", "clean:cache": "rm -rf apps/*/.next apps/*/.swc apps/*/.turbo packages/*/.turbo tooling/*/.turbo .turbo node_modules/.cache", "restart": "bun kill && bun clean:cache && bun dev", diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 7df199bbfb..4077788d16 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -440,13 +440,20 @@ export { getWallArcData, getWallChordFrame, getWallCurveFrameAt, + getWallCurveFrameAtDistance, getWallCurveLength, + getWallCurveSampleCount, + getWallCurveSampledLength, getWallMidpointHandlePoint, + getWallPointAtDistance, getWallStraightSnapOffset, getWallSurfacePolygon, isCurvedWall, normalizeWallCurveOffset, + projectPointToWallCenterline, sampleWallCenterline, + type WallCenterlineProjection, + type WallCurveFrame, } from './systems/wall/wall-curve' export { DEFAULT_WALL_HEIGHT, diff --git a/packages/core/src/lib/slab-polygon.ts b/packages/core/src/lib/slab-polygon.ts index 3aa8ffc894..503472f78d 100644 --- a/packages/core/src/lib/slab-polygon.ts +++ b/packages/core/src/lib/slab-polygon.ts @@ -78,7 +78,6 @@ const MIN_SUBEDGE_LENGTH = 0.05 * Otherwise the nearest centerline wins (parallel close walls). */ const WALL_LATERAL_TIE_EPSILON = 0.02 -const CURVED_WALL_SAMPLE_SEGMENTS = 32 const SLAB_SEAM_ELEVATION_EPSILON = 1e-4 const DEFAULT_SLAB_ELEVATION = 0.05 const DEFAULT_SLAB_THICKNESS = 0.05 @@ -252,7 +251,7 @@ function wallCenterlineSegments(wall: WallNode): Segment[] { return [[wall.start[0], wall.start[1], wall.end[0], wall.end[1]]] } - const points = sampleWallCenterline(wall, CURVED_WALL_SAMPLE_SEGMENTS) + const points = sampleWallCenterline(wall) const segments: Segment[] = [] for (let index = 0; index < points.length - 1; index += 1) { const from = points[index]! diff --git a/packages/core/src/lib/zone-quantities.ts b/packages/core/src/lib/zone-quantities.ts index b1830696d3..d672afeea0 100644 --- a/packages/core/src/lib/zone-quantities.ts +++ b/packages/core/src/lib/zone-quantities.ts @@ -261,7 +261,7 @@ function wallForBoundarySegment( function wallPathsFor(walls: readonly WallNode[]): WallPath[] { return walls.map((wall) => ({ wall, - points: sampleWallCenterline(wall, 32).map((point) => [point.x, point.y] as Point2D), + points: sampleWallCenterline(wall).map((point) => [point.x, point.y] as Point2D), })) } diff --git a/packages/core/src/schema/host-children.test.ts b/packages/core/src/schema/host-children.test.ts index 85e9858d7a..855af5ef12 100644 --- a/packages/core/src/schema/host-children.test.ts +++ b/packages/core/src/schema/host-children.test.ts @@ -11,7 +11,7 @@ import { import { ColumnNode } from './nodes/column' import { AnyNode } from './types' -const hosts = ['shelf', 'cabinet', 'cabinet-module', 'block', 'item', 'column'] as const +const hosts = ['shelf', 'cabinet', 'cabinet-module', 'block', 'item', 'column', 'wall'] as const const schemas = authoredNodeSchemas() const previous = useScene.getState() const previousCompiled = compiledNodeParsersEnabled() @@ -37,14 +37,11 @@ function load(nodes: Record<string, unknown>, rootId: string) { test('frozen pre-slice corpus covers every existing kind', () => { expect(baseline.map((n) => n.type).sort()).toEqual([...NODE_KINDS].sort()) }) -test.each( - baseline, -)('pre-slice $type parses, saves and loads without changing existing fields', (saved) => { +test.each(baseline)('pre-slice $type parses and load migrations are stable', (saved) => { const expected = saved.type === 'column' ? { ...saved, children: [] } : saved expect(JSON.parse(JSON.stringify(AnyNode.parse(saved)))).toEqual(expected) - const graph = { [saved.id]: expected } - expect(load({ [saved.id]: saved }, saved.id)).toEqual(graph) - expect(load(graph, saved.id)).toEqual(graph) + const loaded = load({ [saved.id]: saved }, saved.id) + expect(load(loaded, saved.id)).toEqual(loaded) }) describe.each([false, true])('host children with compiled parsers = %s', (compiled) => { diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts index 3edfbea636..7027867b3e 100644 --- a/packages/core/src/schema/index.ts +++ b/packages/core/src/schema/index.ts @@ -334,6 +334,7 @@ export { getWallFaceBandForHeight, getWallSurfaceMaterialSignature, getWallSurfaceSideFromBandSlot, + validateWallRelations, WALL_CHAIR_RAIL_DEFAULT, WALL_CHAIR_RAIL_SLOT_DEFAULT, WALL_CROWN_DEFAULT, diff --git a/packages/core/src/schema/nodes/wall-relations.ts b/packages/core/src/schema/nodes/wall-relations.ts new file mode 100644 index 0000000000..ea272656e2 --- /dev/null +++ b/packages/core/src/schema/nodes/wall-relations.ts @@ -0,0 +1,33 @@ +import type { AnyNode } from '../types' + +type WallRelationNode = { + id: string + type: 'wall' + children?: readonly string[] +} + +/** Validate the bidirectional ownership index for a wall and its hosted items. */ +export function validateWallRelations( + wall: WallRelationNode, + nodes: Readonly<Record<string, AnyNode>>, +) { + const children = wall.children ?? [] + const listed = new Set<string>() + + for (const childId of children) { + if (listed.has(childId)) + throw new Error(`Wall ${wall.id} lists child ${childId} more than once`) + listed.add(childId) + + const child = nodes[childId] + if (!child) throw new Error(`Wall ${wall.id} references missing child ${childId}`) + if (child.parentId !== wall.id) + throw new Error(`Wall ${wall.id} does not own listed child ${child.id}`) + } + + for (const child of Object.values(nodes)) { + if (child.parentId !== wall.id) continue + if (!listed.has(child.id)) + throw new Error(`Wall ${wall.id} is missing owned child ${child.id} from children`) + } +} diff --git a/packages/core/src/schema/nodes/wall.test.ts b/packages/core/src/schema/nodes/wall.test.ts index a508b0f814..d99920b519 100644 --- a/packages/core/src/schema/nodes/wall.test.ts +++ b/packages/core/src/schema/nodes/wall.test.ts @@ -3,6 +3,7 @@ import { buildEnabledWallFaceBandPatch, buildWallFaceBandCountPatch, getWallFaceBandConfig, + validateWallRelations, WALL_CHAIR_RAIL_DEFAULT, WALL_CHAIR_RAIL_SLOT_DEFAULT, WALL_CROWN_DEFAULT, @@ -35,6 +36,60 @@ describe('wall support offset', () => { true, ) }) + + test('rejects non-finite and non-positive wall dimensions', () => { + for (const patch of [ + { thickness: 0 }, + { thickness: -0.1 }, + { thickness: Number.NaN }, + { height: 0 }, + { height: Number.POSITIVE_INFINITY }, + { curveOffset: Number.NaN }, + { start: [Number.POSITIVE_INFINITY, 0] }, + ]) { + expect(WallNode.safeParse({ start: [0, 0], end: [4, 0], ...patch }).success).toBe(false) + } + }) +}) + +describe('wall child ownership', () => { + const wall = WallNode.parse({ id: 'wall_host', start: [0, 0], end: [4, 0] }) + const item = { id: 'item_child', type: 'item' as const, parentId: wall.id } as never + + test('accepts a listed child whose parent points back to the wall', () => { + expect(() => + validateWallRelations({ ...wall, children: [item.id] }, { [wall.id]: wall, [item.id]: item }), + ).not.toThrow() + }) + + test('accepts plugin-defined children without teaching core their kind', () => { + const pluginChild = { + id: 'fixture:plugin_child', + type: 'fixture:plugin', + parentId: wall.id, + } as never + expect(() => + validateWallRelations( + { ...wall, children: [pluginChild.id] }, + { [wall.id]: wall, [pluginChild.id]: pluginChild }, + ), + ).not.toThrow() + }) + + test('rejects missing, mismatched, and unlisted owned children', () => { + expect(() => + validateWallRelations({ ...wall, children: ['item_missing'] }, { [wall.id]: wall }), + ).toThrow('references missing child') + expect(() => + validateWallRelations( + { ...wall, children: [item.id] }, + { [wall.id]: wall, [item.id]: { ...item, parentId: null } }, + ), + ).toThrow('does not own listed child') + expect(() => validateWallRelations(wall, { [wall.id]: wall, [item.id]: item })).toThrow( + 'missing owned child', + ) + }) }) describe('wall face bands', () => { diff --git a/packages/core/src/schema/nodes/wall.ts b/packages/core/src/schema/nodes/wall.ts index ce2258e2d3..13e9c4bd0d 100644 --- a/packages/core/src/schema/nodes/wall.ts +++ b/packages/core/src/schema/nodes/wall.ts @@ -2,10 +2,9 @@ import dedent from 'dedent' import { z } from 'zod' import { BaseNode, nodeType, objectId } from '../base' import { MaterialSchema } from '../material' -import { DoorNode } from './door' -import { ItemNode } from './item' -import { LeanToExtensionNode } from './lean-to-extension' -import { WindowNode } from './window' +import { validateWallRelations } from './wall-relations' + +export { validateWallRelations } from './wall-relations' export const WallTreatmentSide = z.enum(['interior', 'exterior', 'both']) export type WallTreatmentSide = z.infer<typeof WallTreatmentSide> @@ -131,17 +130,7 @@ export type WallSurfaceSlotId = keyof typeof WALL_SURFACE_SLOT_DEFAULTS export const WallNode = BaseNode.extend({ id: objectId('wall'), type: nodeType('wall'), - children: z - .array( - z.union([ - ItemNode.shape.id, - objectId('procedural-item'), - DoorNode.shape.id, - WindowNode.shape.id, - LeanToExtensionNode.shape.id, - ]), - ) - .default([]), + children: z.array(z.string()).default([]), // Legacy single-material wall finish. Read for backward compatibility only. material: MaterialSchema.optional(), // Legacy single-material wall finish preset. Read for backward compatibility only. @@ -157,9 +146,9 @@ export const WallNode = BaseNode.extend({ // read only by the load migration that moves them into `slots`; delete them // in a follow-up once migrated scenes are the norm. slots: z.record(z.string(), z.string()).optional(), - thickness: z.number().optional(), - height: z.number().optional(), - curveOffset: z.number().optional(), + thickness: z.number().finite().positive().optional(), + height: z.number().finite().positive().optional(), + curveOffset: z.number().finite().optional(), // Persisted slab-support host — see ItemNode.supportSlabId for the rules. supportSlabId: z.string().optional(), // Vertical offset from the elected support surface. Ground-hosted chained @@ -173,13 +162,14 @@ export const WallNode = BaseNode.extend({ crown: WallTrimConfig.optional(), chairRail: WallTrimConfig.optional(), // e.g., start/end points for path - start: z.tuple([z.number(), z.number()]), - end: z.tuple([z.number(), z.number()]), + start: z.tuple([z.number().finite(), z.number().finite()]), + end: z.tuple([z.number().finite(), z.number().finite()]), // Space detection for cutaway mode frontSide: z.enum(['interior', 'exterior', 'unknown']).default('unknown'), backSide: z.enum(['interior', 'exterior', 'unknown']).default('unknown'), -}).describe( - dedent` +}) + .describe( + dedent` Wall node - used to represent a wall in the building - thickness: thickness in meters - height: height in meters @@ -191,7 +181,8 @@ export const WallNode = BaseNode.extend({ - frontSide: whether the front side faces interior, exterior, or unknown - backSide: whether the back side faces interior, exterior, or unknown `, -) + ) + .meta({ validateRelations: validateWallRelations }) export type WallNode = z.infer<typeof WallNode> export type WallSurfaceSide = 'interior' | 'exterior' diff --git a/packages/core/src/store/actions/node-actions.ts b/packages/core/src/store/actions/node-actions.ts index 44d4970bc9..c4fa07445a 100644 --- a/packages/core/src/store/actions/node-actions.ts +++ b/packages/core/src/store/actions/node-actions.ts @@ -26,7 +26,12 @@ import { type WallNode, } from '../../schema' import type { CollectionId } from '../../schema/collections' -import { constrainWallCurveOffsetToAvoidIntersections } from '../../systems/wall/wall-curve' +import { + constrainWallCurveOffsetToAvoidIntersections, + getWallCurveLength, + getWallPointAtDistance, + projectPointToWallCenterline, +} from '../../systems/wall/wall-curve' import { areWallStylesCompatible, areWallsCollinearAcrossPoint, diff --git a/packages/core/src/store/use-scene-wall-slot-migration.test.ts b/packages/core/src/store/use-scene-wall-slot-migration.test.ts index 976d248e5b..42c0d7f849 100644 --- a/packages/core/src/store/use-scene-wall-slot-migration.test.ts +++ b/packages/core/src/store/use-scene-wall-slot-migration.test.ts @@ -106,6 +106,56 @@ describe('wall surface-material → slots migration', () => { expect(wall.exteriorMaterialPreset).toBeUndefined() }) + test('repairs legacy wall dimensions before strict schema parsing', () => { + useScene.getState().setScene( + sceneWithWall({ + thickness: 0, + height: Number.NaN, + curveOffset: Number.POSITIVE_INFINITY, + start: [null, 2], + end: [null, 2], + }), + ['site_test'] as never, + ) + + const wall = useScene.getState().nodes.wall_test as WallNode + expect(wall.thickness).toBeUndefined() + expect(wall.height).toBeUndefined() + expect(wall.curveOffset).toBeUndefined() + expect(wall.start).toEqual([0, 2]) + expect(wall.end).toEqual([3, 2]) + }) + + test('normalizes bare catalog preset IDs from older projects', () => { + useScene + .getState() + .setScene(sceneWithWall({ materialPreset: 'preset-white' }), ['site_test'] as never) + + const wall = useScene.getState().nodes.wall_test as WallNode + expect(wall.slots).toEqual({ + interior: 'library:preset-white', + exterior: 'library:preset-white', + }) + expect(wall.materialPreset).toBeUndefined() + }) + + test('completes a partially migrated wall without overwriting existing slots', () => { + useScene.getState().setScene( + sceneWithWall({ + slots: { interior: 'library:concrete-plate' }, + exteriorMaterialPreset: 'preset-white', + }), + ['site_test'] as never, + ) + + const wall = useScene.getState().nodes.wall_test as WallNode + expect(wall.slots).toEqual({ + interior: 'library:concrete-plate', + exterior: 'library:preset-white', + }) + expect(wall.exteriorMaterialPreset).toBeUndefined() + }) + test('mints a scene material for an inline legacy material and references it', () => { useScene.getState().setScene( sceneWithWall({ diff --git a/packages/core/src/store/use-scene.ts b/packages/core/src/store/use-scene.ts index 5e45cf7402..e1eea6438d 100644 --- a/packages/core/src/store/use-scene.ts +++ b/packages/core/src/store/use-scene.ts @@ -3,7 +3,12 @@ import type { TemporalState } from 'zundo' import { temporal } from 'zundo' import { create, type StateCreator, type StoreApi, type UseBoundStore } from 'zustand' -import { parseMaterialRef, toSceneMaterialRef } from '../material-library' +import { + getCatalogMaterialById, + parseMaterialRef, + toLibraryMaterialRef, + toSceneMaterialRef, +} from '../material-library' import { getNodePluginId, isNodeKindEnabled, nodeRegistry } from '../registry/registry' import { BuildingNode } from '../schema' import type { Collection, CollectionId } from '../schema/collections' @@ -312,8 +317,10 @@ function legacySpecToMaterialRef( spec: { material?: unknown; materialPreset?: unknown }, mintedMaterials: Record<SceneMaterialId, SceneMaterial>, ): string | undefined { - if (typeof spec.materialPreset === 'string' && parseMaterialRef(spec.materialPreset)) { - return spec.materialPreset + if (typeof spec.materialPreset === 'string') { + if (parseMaterialRef(spec.materialPreset)) return spec.materialPreset + const legacyId = spec.materialPreset.trim() + if (getCatalogMaterialById(legacyId)) return toLibraryMaterialRef(legacyId) } if (spec.material !== undefined) { const existing = findMintedSceneMaterialRef(spec.material, mintedMaterials) @@ -338,18 +345,16 @@ function migrateWallSurfaceMaterials( node: Record<string, any>, mintedMaterials: Record<SceneMaterialId, SceneMaterial>, ) { - if (node.slots && (node.slots.interior !== undefined || node.slots.exterior !== undefined)) { - return node - } - const slots: Record<string, string> = { ...(node.slots ?? {}) } for (const side of ['interior', 'exterior'] as WallSurfaceSide[]) { const spec = getEffectiveWallSurfaceMaterial( node as Parameters<typeof getEffectiveWallSurfaceMaterial>[0], side, ) - const ref = legacySpecToMaterialRef(spec, mintedMaterials) - if (ref) slots[side] = ref + if (slots[side] === undefined) { + const ref = legacySpecToMaterialRef(spec, mintedMaterials) + if (ref) slots[side] = ref + } } if (Object.keys(slots).length === 0) { @@ -368,6 +373,46 @@ function migrateWallSurfaceMaterials( } } +function normalizeWallGeometry(node: Record<string, any>) { + const point = (value: unknown, fallback: [number, number]): [number, number] => { + if (!Array.isArray(value)) return fallback + return [getFiniteNumber(value[0], fallback[0]), getFiniteNumber(value[1], fallback[1])] + } + const start = Array.isArray(node.start) ? point(node.start, [0, 0]) : node.start + let end = Array.isArray(node.end) + ? point(node.end, Array.isArray(start) ? [start[0] + 3, start[1]] : [3, 0]) + : node.end + if ( + Array.isArray(start) && + Array.isArray(end) && + Math.hypot(end[0] - start[0], end[1] - start[1]) <= 1e-6 + ) { + end = [start[0] + 3, start[1]] + } + const next: Record<string, any> = { + ...node, + children: getStringArray(node.children), + start, + end, + } + for (const field of ['thickness', 'height'] as const) { + const value = next[field] + if ( + value !== undefined && + !(typeof value === 'number' && Number.isFinite(value) && value > 0) + ) { + delete next[field] + } + } + for (const field of ['curveOffset', 'supportOffset'] as const) { + const value = next[field] + if (value !== undefined && !(typeof value === 'number' && Number.isFinite(value))) { + delete next[field] + } + } + return next +} + // Move a kind's single legacy `material` / `materialPreset` onto its declared // slots. A pre-slot-model node painted one material rendered that material on // every part (each slot resolves `node.slots[slot]` → legacy → default), so the @@ -880,6 +925,7 @@ function migrateNodes(nodes: Record<string, any>): { } if (node.type === 'wall') { + patchedNodes[id] = normalizeWallGeometry(patchedNodes[id]) patchedNodes[id] = migrateWallSurfaceMaterials( migrateWallAssembly(patchedNodes[id]), mintedMaterials, diff --git a/packages/core/src/systems/fence/fence-centerline.ts b/packages/core/src/systems/fence/fence-centerline.ts index 0cfc402cfa..c9faffc6f1 100644 --- a/packages/core/src/systems/fence/fence-centerline.ts +++ b/packages/core/src/systems/fence/fence-centerline.ts @@ -1,5 +1,9 @@ import type { FenceNode } from '../../schema' -import { getWallCurveFrameAt, getWallCurveLength, sampleWallCenterline } from '../wall/wall-curve' +import { + getWallCurveFrameAt, + getWallCurveSampledLength, + sampleWallCenterline, +} from '../wall/wall-curve' import type { Point2D } from '../wall/wall-mitering' import { getFenceSplineFrameAt, @@ -19,7 +23,7 @@ import { * math in `wall-curve.ts` is untouched — walls never carry a `path`. */ -const DEFAULT_SAMPLE_SEGMENTS = 96 +const DEFAULT_SPLINE_SAMPLE_SEGMENTS = 96 type CurveFrame = { point: Point2D @@ -34,28 +38,22 @@ export function getFenceCenterlineFrameAt(fence: FenceNode, t: number): CurveFra return getWallCurveFrameAt(fence, t) } -export function sampleFenceCenterline( - fence: FenceNode, - segments = DEFAULT_SAMPLE_SEGMENTS, -): Point2D[] { +export function sampleFenceCenterline(fence: FenceNode, segments?: number): Point2D[] { if (isSplineFence(fence) && fence.path) { // Spread the requested sample budget across the spans so a long path still // reads smoothly without exploding the point count. const spanCount = Math.max(1, fence.path.length - 1) - const perSpan = Math.max(2, Math.ceil(segments / spanCount)) + const perSpan = Math.max(2, Math.ceil((segments ?? DEFAULT_SPLINE_SAMPLE_SEGMENTS) / spanCount)) return sampleFenceSpline(fence.path, fence.tangents, perSpan) } return sampleWallCenterline(fence, segments) } -export function getFenceCenterlineLength( - fence: FenceNode, - segments = DEFAULT_SAMPLE_SEGMENTS, -): number { +export function getFenceCenterlineLength(fence: FenceNode, segments?: number): number { if (isSplineFence(fence) && fence.path) { const spanCount = Math.max(1, fence.path.length - 1) - const perSpan = Math.max(2, Math.ceil(segments / spanCount)) + const perSpan = Math.max(2, Math.ceil((segments ?? DEFAULT_SPLINE_SAMPLE_SEGMENTS) / spanCount)) return getFenceSplineLength(fence.path, fence.tangents, perSpan) } - return getWallCurveLength(fence, segments) + return getWallCurveSampledLength(fence, segments) } diff --git a/packages/core/src/systems/wall/wall-attachments.ts b/packages/core/src/systems/wall/wall-attachments.ts new file mode 100644 index 0000000000..d24d086a49 --- /dev/null +++ b/packages/core/src/systems/wall/wall-attachments.ts @@ -0,0 +1,97 @@ +import { + type AnyNode, + type AnyNodeId, + type DoorNode, + getScaledDimensions, + type ItemNode, + type WallNode, + type WindowNode, +} from '../../schema' +import { getWallArcData, getWallCurveFrameAt, getWallCurveLength, isCurvedWall } from './wall-curve' +import type { WallPlanPoint } from './wall-move' + +const WALL_INTERSECTION_EPSILON = 1e-6 + +export function wallLength(wall: WallNode) { + return isCurvedWall(wall) + ? getWallCurveLength(wall) + : Math.hypot(wall.end[0] - wall.start[0], wall.end[1] - wall.start[1]) +} + +export function wallPointAt(wall: WallNode, wallT: number): WallPlanPoint { + if (wallT <= WALL_INTERSECTION_EPSILON) return wall.start + if (wallT >= 1 - WALL_INTERSECTION_EPSILON) return wall.end + const frame = getWallCurveFrameAt(wall, wallT) + return [frame.point.x, frame.point.y] +} + +export function segmentCurveOffset(wall: WallNode, startT: number, endT: number) { + const arc = getWallArcData(wall) + if (!arc) return wall.curveOffset + const angle = Math.abs(arc.delta) * (endT - startT) + return arc.direction * arc.radius * (1 - Math.cos(angle / 2)) +} + +export function getWallAttachmentSpan( + node: AnyNode, +): { min: number; max: number; center: number } | null { + if (node.type === 'door') { + const door = node as DoorNode + return { + min: door.position[0] - door.width / 2, + max: door.position[0] + door.width / 2, + center: door.position[0], + } + } + if (node.type === 'window') { + const window = node as WindowNode + return { + min: window.position[0] - window.width / 2, + max: window.position[0] + window.width / 2, + center: window.position[0], + } + } + if (node.type === 'item') { + const item = node as ItemNode + if (item.asset.attachTo !== 'wall' && item.asset.attachTo !== 'wall-side') return null + const [width] = getScaledDimensions(item) + return { + min: item.position[0] - width / 2, + max: item.position[0] + width / 2, + center: item.position[0], + } + } + return null +} + +export function getWallAttachments(wall: WallNode, nodes: Record<AnyNodeId, AnyNode>) { + const ids = new Set<AnyNodeId>((wall.children ?? []) as AnyNodeId[]) + for (const node of Object.values(nodes)) { + if ( + node.parentId === wall.id || + ('wallId' in node && typeof node.wallId === 'string' && node.wallId === wall.id) + ) { + ids.add(node.id) + } + } + return [...ids].flatMap((id) => { + const node = nodes[id] + return node ? [node] : [] + }) +} + +export function remapWallAttachment( + node: AnyNode, + wall: WallNode, + nextLocalX: number, +): Partial<AnyNode> | null { + if (!(node.type === 'door' || node.type === 'window' || node.type === 'item')) return null + const nextLength = wallLength(wall) + const clampedX = Math.max(0, Math.min(nextLength, nextLocalX)) + return { + parentId: wall.id, + wallId: wall.id, + position: [clampedX, node.position[1], node.position[2]], + ...(node.type === 'item' ? { wallT: nextLength > 1e-6 ? clampedX / nextLength : 0 } : {}), + } as Partial<AnyNode> +} diff --git a/packages/core/src/systems/wall/wall-curve.test.ts b/packages/core/src/systems/wall/wall-curve.test.ts new file mode 100644 index 0000000000..87d4504c9e --- /dev/null +++ b/packages/core/src/systems/wall/wall-curve.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, test } from 'bun:test' +import { WallNode } from '../../schema' +import { + getWallCurveFrameAtDistance, + getWallCurveLength, + getWallCurveSampleCount, + getWallCurveSampledLength, + getWallPointAtDistance, + projectPointToWallCenterline, + sampleWallCenterline, +} from './wall-curve' + +describe('wall centerline geometry kernel', () => { + test('uses chord distance for straight walls', () => { + const wall = WallNode.parse({ start: [0, 0], end: [3, 4] }) + expect(getWallCurveLength(wall)).toBe(5) + expect(getWallCurveSampledLength(wall, 4)).toBe(5) + expect(getWallPointAtDistance(wall, 2.5)).toEqual({ x: 1.5, y: 2 }) + }) + + test('uses circular arc length for curved walls', () => { + const wall = WallNode.parse({ start: [-2, 0], end: [2, 0], curveOffset: 1 }) + const arc = getWallCurveLength(wall) + const midpoint = getWallCurveFrameAtDistance(wall, arc / 2) + expect(arc).toBeGreaterThan(4) + expect(Math.abs(midpoint.point.y)).toBeCloseTo(1, 6) + expect(Math.abs(midpoint.tangent.x)).toBeCloseTo(1, 6) + }) + + test('adapts curve samples to curvature while keeping chord error bounded', () => { + const shallow = WallNode.parse({ start: [0, 0], end: [10, 0], curveOffset: 0.2 }) + const strong = WallNode.parse({ start: [0, 0], end: [10, 0], curveOffset: 4 }) + expect(getWallCurveSampleCount(shallow)).toBeLessThan(getWallCurveSampleCount(strong)) + + const tolerance = 0.005 + const count = getWallCurveSampleCount(strong, tolerance) + const points = sampleWallCenterline(strong, count) + let maxError = 0 + for (let index = 0; index < count; index += 1) { + const midpoint = getWallCurveFrameAtDistance( + strong, + (getWallCurveLength(strong) * (index + 0.5)) / count, + ).point + const chordMidpoint = { + x: (points[index]!.x + points[index + 1]!.x) / 2, + y: (points[index]!.y + points[index + 1]!.y) / 2, + } + maxError = Math.max( + maxError, + Math.hypot(midpoint.x - chordMidpoint.x, midpoint.y - chordMidpoint.y), + ) + } + expect(maxError).toBeLessThanOrEqual(tolerance + 1e-6) + }) + + test('projects onto the nearest point while preserving signed normal distance', () => { + const wall = WallNode.parse({ start: [-2, 0], end: [2, 0], curveOffset: 1 }) + const frame = getWallCurveFrameAtDistance(wall, getWallCurveLength(wall) * 0.35) + const projected = projectPointToWallCenterline(wall, { + x: frame.point.x + frame.normal.x * 0.25, + y: frame.point.y + frame.normal.y * 0.25, + }) + expect(projected.distanceAlong).toBeCloseTo(getWallCurveLength(wall) * 0.35, 5) + expect(projected.signedNormalDistance).toBeCloseTo(0.25, 5) + }) +}) diff --git a/packages/core/src/systems/wall/wall-curve.ts b/packages/core/src/systems/wall/wall-curve.ts index 8f3e8a739c..d86b3c2973 100644 --- a/packages/core/src/systems/wall/wall-curve.ts +++ b/packages/core/src/systems/wall/wall-curve.ts @@ -2,13 +2,14 @@ import type { FenceNode, WallNode } from '../../schema' import type { Point2D } from './wall-mitering' const CURVE_EPSILON = 1e-6 -const DEFAULT_SAMPLE_SEGMENTS = 24 +const DEFAULT_CURVE_TOLERANCE = 0.005 +const MAX_ADAPTIVE_CURVE_SEGMENTS = 128 const CURVE_INTERSECTION_SEARCH_STEPS = 20 const CURVE_INTERSECTION_TOLERANCE = 1e-6 type WallCurveLike = Pick<WallNode | FenceNode, 'start' | 'end' | 'curveOffset'> -type CurveFrame = { +export type WallCurveFrame = { point: Point2D tangent: Point2D normal: Point2D @@ -137,7 +138,7 @@ export function getWallArcData(wall: WallCurveLike) { return { center, radius, startAngle, delta, direction } } -export function getWallCurveFrameAt(wall: WallCurveLike, t: number): CurveFrame { +export function getWallCurveFrameAt(wall: WallCurveLike, t: number): WallCurveFrame { const chord = getWallChordFrame(wall) if (!isCurvedWall(wall) || chord.length < CURVE_EPSILON) { return { @@ -183,14 +184,114 @@ export function getWallMidpointHandlePoint(wall: WallCurveLike) { return getWallCurveFrameAt(wall, 0.5).point } -export function sampleWallCenterline(wall: WallCurveLike, segments = DEFAULT_SAMPLE_SEGMENTS) { - const count = Math.max(1, segments) +/** Return a tessellation budget whose chord error stays below `tolerance`. */ +export function getWallCurveSampleCount( + wall: WallCurveLike, + tolerance = DEFAULT_CURVE_TOLERANCE, + maxSegments = MAX_ADAPTIVE_CURVE_SEGMENTS, +) { + const arc = getWallArcData(wall) + if (!arc) return 1 + const safeTolerance = Math.max(CURVE_EPSILON, tolerance) + const angleStep = + 2 * + Math.acos(Math.max(-1, Math.min(1, 1 - safeTolerance / Math.max(arc.radius, safeTolerance)))) + const count = Math.ceil(Math.abs(arc.delta) / Math.max(angleStep, CURVE_EPSILON)) + return Math.max(2, Math.min(Math.max(2, Math.floor(maxSegments)), count)) +} + +export function sampleWallCenterline(wall: WallCurveLike, segments?: number) { + const count = segments === undefined ? getWallCurveSampleCount(wall) : Math.max(1, segments) return Array.from( { length: count + 1 }, (_, index) => getWallCurveFrameAt(wall, index / count).point, ) } +/** Return the centerline frame at a distance from the wall's start point. */ +export function getWallCurveFrameAtDistance( + wall: WallCurveLike, + distanceAlong: number, +): WallCurveFrame { + const length = getWallCurveLength(wall) + return getWallCurveFrameAt(wall, length <= CURVE_EPSILON ? 0 : distanceAlong / length) +} + +export function getWallPointAtDistance(wall: WallCurveLike, distanceAlong: number): Point2D { + return getWallCurveFrameAtDistance(wall, distanceAlong).point +} + +export type WallCenterlineProjection = { + point: Point2D + frame: WallCurveFrame + distance: number + distanceAlong: number + t: number + signedNormalDistance: number +} + +/** Project a plan point onto the wall centerline, including curved walls. */ +export function projectPointToWallCenterline( + wall: WallCurveLike, + planPoint: Point2D, +): WallCenterlineProjection { + const chord = getWallChordFrame(wall) + if (!isCurvedWall(wall) || chord.length <= CURVE_EPSILON) { + const dx = planPoint.x - chord.start.x + const dy = planPoint.y - chord.start.y + const t = + chord.length <= CURVE_EPSILON + ? 0 + : clamp01((dx * chord.tangent.x + dy * chord.tangent.y) / chord.length) + const frame = getWallCurveFrameAt(wall, t) + const projected = frame.point + const signedNormalDistance = + (planPoint.x - projected.x) * frame.normal.x + (planPoint.y - projected.y) * frame.normal.y + return { + point: projected, + frame, + distance: Math.hypot(planPoint.x - projected.x, planPoint.y - projected.y), + distanceAlong: chord.length * t, + t, + signedNormalDistance, + } + } + + const arc = getWallArcData(wall) + if (!arc) return projectPointToWallCenterline({ ...wall, curveOffset: 0 }, planPoint) + + const pointAngle = Math.atan2(planPoint.y - arc.center.y, planPoint.x - arc.center.x) + let directedAngle = (pointAngle - arc.startAngle) * arc.direction + while (directedAngle < 0) directedAngle += Math.PI * 2 + const arcAngle = Math.abs(arc.delta) + const candidates = [0, 1] + if (directedAngle <= arcAngle) candidates.push(directedAngle / arcAngle) + + let bestT = 0 + let bestDistance = Number.POSITIVE_INFINITY + for (const t of candidates) { + const frame = getWallCurveFrameAt(wall, t) + const candidateDistance = Math.hypot(planPoint.x - frame.point.x, planPoint.y - frame.point.y) + if (candidateDistance < bestDistance) { + bestDistance = candidateDistance + bestT = t + } + } + + const frame = getWallCurveFrameAt(wall, bestT) + const signedNormalDistance = + (planPoint.x - frame.point.x) * frame.normal.x + (planPoint.y - frame.point.y) * frame.normal.y + const distanceAlong = getWallCurveLength(wall) * bestT + return { + point: frame.point, + frame, + distance: bestDistance, + distanceAlong, + t: bestT, + signedNormalDistance, + } +} + function segmentIntersectionPoint(a: Point2D, b: Point2D, c: Point2D, d: Point2D): Point2D | null { const abX = b.x - a.x const abY = b.y - a.y @@ -278,7 +379,7 @@ export function constrainWallCurveOffsetToAvoidIntersections( return normalizeWallCurveOffset(wall, safeOffset) } -export function getWallCurveLength(wall: WallCurveLike, segments = DEFAULT_SAMPLE_SEGMENTS) { +export function getWallCurveSampledLength(wall: WallCurveLike, segments?: number) { const points = sampleWallCenterline(wall, segments) let totalLength = 0 @@ -289,13 +390,19 @@ export function getWallCurveLength(wall: WallCurveLike, segments = DEFAULT_SAMPL return totalLength } +/** Exact centerline length for straight and circular wall centerlines. */ +export function getWallCurveLength(wall: WallCurveLike) { + const arc = getWallArcData(wall) + return arc ? Math.abs(arc.radius * arc.delta) : getWallChordLength(wall) +} + export function getWallSurfacePolygon( wall: Pick<WallNode | FenceNode, 'start' | 'end' | 'curveOffset' | 'thickness'>, - segments = DEFAULT_SAMPLE_SEGMENTS, + segments?: number, miterOverrides?: WallSurfaceMiterOverrides, ) { const halfThickness = (wall.thickness ?? 0.1) / 2 - const count = Math.max(1, segments) + const count = segments === undefined ? getWallCurveSampleCount(wall) : Math.max(1, segments) const left: Point2D[] = [] const right: Point2D[] = [] diff --git a/packages/core/src/systems/wall/wall-footprint.ts b/packages/core/src/systems/wall/wall-footprint.ts index 585daf3b23..61782edd6d 100644 --- a/packages/core/src/systems/wall/wall-footprint.ts +++ b/packages/core/src/systems/wall/wall-footprint.ts @@ -11,7 +11,6 @@ export { calculateLevelMiters, type Point2D, type WallMiterData } from './wall-m export const DEFAULT_WALL_THICKNESS = 0.1 export const DEFAULT_WALL_HEIGHT = 2.5 -const CURVED_WALL_SURFACE_SEGMENTS = 24 export function getWallThickness(wallNode: WallNode): number { return wallNode.thickness ?? DEFAULT_WALL_THICKNESS @@ -43,7 +42,7 @@ export function getWallPlanFootprint(wallNode: WallNode, miterData: WallMiterDat const { startLeft, startRight, endLeft, endRight } = boundaryPoints - return getWallSurfacePolygon(wallNode, CURVED_WALL_SURFACE_SEGMENTS, { + return getWallSurfacePolygon(wallNode, undefined, { endLeft, endRight, startLeft, diff --git a/packages/core/src/systems/wall/wall-intersections.ts b/packages/core/src/systems/wall/wall-intersections.ts new file mode 100644 index 0000000000..20e4842282 --- /dev/null +++ b/packages/core/src/systems/wall/wall-intersections.ts @@ -0,0 +1,157 @@ +import type { WallNode } from '../../schema' +import { getWallArcData, getWallCurveFrameAt, isCurvedWall } from './wall-curve' +import type { WallPlanPoint } from './wall-move' + +const WALL_SPLIT_ENDPOINT_EPSILON = 0.02 +const WALL_INTERSECTION_EPSILON = 1e-6 + +export type WallSegmentIntersection = { + wallId: WallNode['id'] + point: WallPlanPoint + draftT: number + wallT: number +} + +export function distanceSquared(a: WallPlanPoint, b: WallPlanPoint) { + const dx = a[0] - b[0] + const dz = a[1] - b[1] + return dx * dx + dz * dz +} + +export function projectPointOntoWallCenterline( + point: WallPlanPoint, + wall: WallNode, +): { point: WallPlanPoint; wallT: number } | null { + if (isCurvedWall(wall)) { + const arc = getWallArcData(wall) + if (!arc) return null + const pointAngle = Math.atan2(point[1] - arc.center.y, point[0] - arc.center.x) + let directedAngle = (pointAngle - arc.startAngle) * arc.direction + while (directedAngle < 0) directedAngle += Math.PI * 2 + const wallT = directedAngle / Math.abs(arc.delta) + if (wallT <= 0 || wallT >= 1) return null + const projected = getWallCurveFrameAt(wall, wallT).point + return { point: [projected.x, projected.y], wallT } + } + + const dx = wall.end[0] - wall.start[0] + const dz = wall.end[1] - wall.start[1] + const lengthSquared = dx * dx + dz * dz + if (lengthSquared < 1e-9) return null + const wallT = ((point[0] - wall.start[0]) * dx + (point[1] - wall.start[1]) * dz) / lengthSquared + if (wallT <= 0 || wallT >= 1) return null + return { + point: [wall.start[0] + dx * wallT, wall.start[1] + dz * wallT], + wallT, + } +} + +export function nearestWallProjection( + point: WallPlanPoint, + walls: WallNode[], + radius: number, + ignoreWallIds: ReadonlySet<string> = new Set(), +) { + let best: { wall: WallNode | null; point: WallPlanPoint; wallT: number } | null = null + let bestDistance = Number.POSITIVE_INFINITY + for (const wall of walls) { + if (ignoreWallIds.has(wall.id)) continue + const projection = projectPointOntoWallCenterline(point, wall) + if (!projection) continue + const candidateDistance = distanceSquared(point, projection.point) + if (candidateDistance > radius * radius || candidateDistance >= bestDistance) continue + const corner = ([wall.start, wall.end] as WallPlanPoint[]).find( + (candidate) => + distanceSquared(projection.point, candidate) <= + WALL_SPLIT_ENDPOINT_EPSILON * WALL_SPLIT_ENDPOINT_EPSILON, + ) + best = corner + ? { wall: null, point: [corner[0], corner[1]], wallT: projection.wallT } + : { wall, ...projection } + bestDistance = candidateDistance + } + return best +} + +export function straightSegmentIntersection( + start: WallPlanPoint, + end: WallPlanPoint, + wall: WallNode, +): WallSegmentIntersection | null { + const rx = end[0] - start[0] + const rz = end[1] - start[1] + const sx = wall.end[0] - wall.start[0] + const sz = wall.end[1] - wall.start[1] + const denominator = rx * sz - rz * sx + if (Math.abs(denominator) < 1e-9) return null + + const offsetX = wall.start[0] - start[0] + const offsetZ = wall.start[1] - start[1] + const draftT = (offsetX * sz - offsetZ * sx) / denominator + const wallT = (offsetX * rz - offsetZ * rx) / denominator + if (draftT <= 0 || draftT >= 1 || wallT < 0 || wallT > 1) return null + + return { + wallId: wall.id, + point: [start[0] + draftT * rx, start[1] + draftT * rz], + draftT, + wallT, + } +} + +export function curvedSegmentIntersections( + start: WallPlanPoint, + end: WallPlanPoint, + wall: WallNode, +): WallSegmentIntersection[] { + const arc = getWallArcData(wall) + if (!arc) return [] + + const dx = end[0] - start[0] + const dz = end[1] - start[1] + const offsetX = start[0] - arc.center.x + const offsetZ = start[1] - arc.center.y + const a = dx * dx + dz * dz + if (a < 1e-12) return [] + + const b = 2 * (offsetX * dx + offsetZ * dz) + const c = offsetX * offsetX + offsetZ * offsetZ - arc.radius * arc.radius + const discriminant = b * b - 4 * a * c + if (discriminant < -1e-9) return [] + + const root = Math.sqrt(Math.max(0, discriminant)) + const results: WallSegmentIntersection[] = [] + for (const rawDraftT of [(-b - root) / (2 * a), (-b + root) / (2 * a)]) { + if (rawDraftT < -1e-9 || rawDraftT > 1 + 1e-9) continue + const point: WallPlanPoint = [start[0] + rawDraftT * dx, start[1] + rawDraftT * dz] + const angle = Math.atan2(point[1] - arc.center.y, point[0] - arc.center.x) + let directedAngle = (angle - arc.startAngle) * arc.direction + while (directedAngle < 0) directedAngle += Math.PI * 2 + const rawWallT = directedAngle / Math.abs(arc.delta) + if (rawWallT < -1e-9 || rawWallT > 1 + 1e-9) continue + if (results.some((candidate) => distanceSquared(candidate.point, point) < 1e-12)) continue + results.push({ + wallId: wall.id, + point, + draftT: Math.max(0, Math.min(1, rawDraftT)), + wallT: Math.max(0, Math.min(1, rawWallT)), + }) + } + return results +} + +export function joinCrossingAtNearbyWallEndpoint( + crossing: WallSegmentIntersection, + walls: WallNode[], +): WallSegmentIntersection { + const wall = walls.find((candidate) => candidate.id === crossing.wallId) + if (!wall) return crossing + const endpointIndex = ([wall.start, wall.end] as WallPlanPoint[]).findIndex( + (endpoint) => + distanceSquared(crossing.point, endpoint) <= + WALL_SPLIT_ENDPOINT_EPSILON * WALL_SPLIT_ENDPOINT_EPSILON, + ) + if (endpointIndex < 0) return crossing + const endpoint = endpointIndex === 0 ? wall.start : wall.end + return { ...crossing, point: [endpoint[0], endpoint[1]], wallT: endpointIndex } +} diff --git a/packages/core/src/systems/wall/wall-junctions.ts b/packages/core/src/systems/wall/wall-junctions.ts new file mode 100644 index 0000000000..c2f7e82c11 --- /dev/null +++ b/packages/core/src/systems/wall/wall-junctions.ts @@ -0,0 +1,142 @@ +import type { WallNode } from '../../schema' +import { getWallArcData, isCurvedWall, projectPointToWallCenterline } from './wall-curve' +import type { Point2D } from './wall-mitering' + +const TOLERANCE = 0.001 +const JUNCTION_GRID_CELL = 2.0 +const JUNCTION_GRID_MAX_CELLS_PER_WALL = 64 + +export interface WallJunction { + meetingPoint: Point2D + connectedWalls: Array<{ wall: WallNode; endType: 'start' | 'end' | 'passthrough' }> +} + +export function pointToKey(p: Point2D, tolerance = TOLERANCE): string { + const snap = 1 / tolerance + return `${Math.round(p.x * snap)},${Math.round(p.y * snap)}` +} + +export function pointOnWallSegment(point: Point2D, wall: WallNode, tolerance = TOLERANCE): boolean { + const start: Point2D = { x: wall.start[0], y: wall.start[1] } + const end: Point2D = { x: wall.end[0], y: wall.end[1] } + if (pointToKey(point, tolerance) === pointToKey(start, tolerance)) return false + if (pointToKey(point, tolerance) === pointToKey(end, tolerance)) return false + + if (isCurvedWall(wall)) { + const projection = projectPointToWallCenterline(wall, point) + return Boolean( + projection && + projection.t > tolerance && + projection.t < 1 - tolerance && + (point.x - projection.point.x) ** 2 + (point.y - projection.point.y) ** 2 <= + tolerance * tolerance, + ) + } + + const v = { x: end.x - start.x, y: end.y - start.y } + const length = Math.hypot(v.x, v.y) + if (length < 1e-9) return false + const w = { x: point.x - start.x, y: point.y - start.y } + const t = (v.x * w.x + v.y * w.y) / (length * length) + if (t < tolerance || t > 1 - tolerance) return false + const projected = { x: start.x + t * v.x, y: start.y + t * v.y } + return Math.hypot(point.x - projected.x, point.y - projected.y) < tolerance +} + +function cellKey(x: number, y: number): string { + return `${Math.floor(x / JUNCTION_GRID_CELL)},${Math.floor(y / JUNCTION_GRID_CELL)}` +} + +function angleOnArc(angle: number, startAngle: number, delta: number) { + let directed = (angle - startAngle) * Math.sign(delta) + while (directed < 0) directed += Math.PI * 2 + return directed <= Math.abs(delta) + 1e-9 +} + +function wallBounds(wall: WallNode) { + const points = [ + { x: wall.start[0], y: wall.start[1] }, + { x: wall.end[0], y: wall.end[1] }, + ] + const arc = getWallArcData(wall) + if (arc) { + for (const angle of [0, Math.PI / 2, Math.PI, (Math.PI * 3) / 2]) { + if (angleOnArc(angle, arc.startAngle, arc.delta)) { + points.push({ + x: arc.center.x + arc.radius * Math.cos(angle), + y: arc.center.y + arc.radius * Math.sin(angle), + }) + } + } + } + return { + minX: Math.min(...points.map((point) => point.x)), + maxX: Math.max(...points.map((point) => point.x)), + minY: Math.min(...points.map((point) => point.y)), + maxY: Math.max(...points.map((point) => point.y)), + } +} + +function buildJunctionGrid(walls: WallNode[]) { + const grid = new Map<string, WallNode[]>() + const oversized: WallNode[] = [] + for (const wall of walls) { + const bounds = wallBounds(wall) + const minX = bounds.minX - TOLERANCE + const maxX = bounds.maxX + TOLERANCE + const minY = bounds.minY - TOLERANCE + const maxY = bounds.maxY + TOLERANCE + const cx0 = Math.floor(minX / JUNCTION_GRID_CELL) + const cx1 = Math.floor(maxX / JUNCTION_GRID_CELL) + const cy0 = Math.floor(minY / JUNCTION_GRID_CELL) + const cy1 = Math.floor(maxY / JUNCTION_GRID_CELL) + if ((cx1 - cx0 + 1) * (cy1 - cy0 + 1) > JUNCTION_GRID_MAX_CELLS_PER_WALL) { + oversized.push(wall) + continue + } + for (let cx = cx0; cx <= cx1; cx++) { + for (let cy = cy0; cy <= cy1; cy++) { + const key = `${cx},${cy}` + const bucket = grid.get(key) + if (bucket) bucket.push(wall) + else grid.set(key, [wall]) + } + } + } + return { grid, oversized } +} + +export function findWallJunctions(walls: WallNode[]): Map<string, WallJunction> { + const junctions = new Map<string, WallJunction>() + for (const wall of walls) { + const startPt: Point2D = { x: wall.start[0], y: wall.start[1] } + const endPt: Point2D = { x: wall.end[0], y: wall.end[1] } + const startKey = pointToKey(startPt) + const endKey = pointToKey(endPt) + if (!junctions.has(startKey)) + junctions.set(startKey, { meetingPoint: startPt, connectedWalls: [] }) + junctions.get(startKey)?.connectedWalls.push({ wall, endType: 'start' }) + if (!junctions.has(endKey)) junctions.set(endKey, { meetingPoint: endPt, connectedWalls: [] }) + junctions.get(endKey)?.connectedWalls.push({ wall, endType: 'end' }) + } + + const { grid, oversized } = buildJunctionGrid(walls) + const wallOrder = new Map(walls.map((wall, index) => [wall.id, index])) + for (const junction of junctions.values()) { + const candidates = grid.get(cellKey(junction.meetingPoint.x, junction.meetingPoint.y)) + const passthrough: WallNode[] = [] + for (const bucket of [candidates, oversized]) { + if (!bucket) continue + for (const wall of bucket) { + if (junction.connectedWalls.some((entry) => entry.wall.id === wall.id)) continue + if (pointOnWallSegment(junction.meetingPoint, wall)) passthrough.push(wall) + } + } + passthrough.sort((a, b) => (wallOrder.get(a.id) ?? 0) - (wallOrder.get(b.id) ?? 0)) + for (const wall of passthrough) { + junction.connectedWalls.push({ wall, endType: 'passthrough' }) + } + } + + return new Map([...junctions].filter(([, junction]) => junction.connectedWalls.length >= 2)) +} diff --git a/packages/core/src/systems/wall/wall-merge.ts b/packages/core/src/systems/wall/wall-merge.ts index f6f85d0e9b..3d12e90ca8 100644 --- a/packages/core/src/systems/wall/wall-merge.ts +++ b/packages/core/src/systems/wall/wall-merge.ts @@ -6,6 +6,11 @@ import { getWallSurfaceMaterialSignature, type WallNode, } from '../../schema' +import { + getWallCurveLength, + getWallPointAtDistance, + projectPointToWallCenterline, +} from './wall-curve' import type { WallTopologyChanges } from './wall-topology' // Joining two walls that continue each other at a shared end: the delete heal @@ -152,12 +157,16 @@ export function buildMergedWallAttachmentUpdates( mergedEnd: [number, number], nodes: Record<AnyNodeId, AnyNode>, ): WallAttachmentUpdate[] { - const mergedLength = Math.max( + // Children are placed by arc distance along the centerline, so a merged curved + // wall keeps them on the curve instead of the chord. + const mergedGeometry = { start: mergedStart, end: mergedEnd, curveOffset: primary.curveOffset } + const mergedLength = Math.max(getWallCurveLength(mergedGeometry), 1e-6) + const chordLength = Math.max( Math.hypot(mergedEnd[0] - mergedStart[0], mergedEnd[1] - mergedStart[1]), 1e-6, ) - const tangentX = (mergedEnd[0] - mergedStart[0]) / mergedLength - const tangentZ = (mergedEnd[1] - mergedStart[1]) / mergedLength + const tangentX = (mergedEnd[0] - mergedStart[0]) / chordLength + const tangentZ = (mergedEnd[1] - mergedStart[1]) / chordLength const updates: WallAttachmentUpdate[] = [] const wallChildren = [...(primary.children ?? []), ...(secondary.children ?? [])] as AnyNodeId[] @@ -173,23 +182,16 @@ export function buildMergedWallAttachmentUpdates( } const sourceWall = child.parentId === secondary.id ? secondary : primary - const sourceLength = Math.max(wallLength(sourceWall), 1e-6) const reversed = (sourceWall.end[0] - sourceWall.start[0]) * tangentX + (sourceWall.end[1] - sourceWall.start[1]) * tangentZ < 0 const mirrored = reversed ? reversedWallChildPatch(child) : {} const localX = typeof child.position[0] === 'number' ? child.position[0] : 0 - const worldX = - sourceWall.start[0] + ((sourceWall.end[0] - sourceWall.start[0]) * localX) / sourceLength - const worldZ = - sourceWall.start[1] + ((sourceWall.end[1] - sourceWall.start[1]) * localX) / sourceLength + const sourcePoint = getWallPointAtDistance(sourceWall, localX) const nextLocalX = Math.max( 0, - Math.min( - mergedLength, - (worldX - mergedStart[0]) * tangentX + (worldZ - mergedStart[1]) * tangentZ, - ), + Math.min(mergedLength, projectPointToWallCenterline(mergedGeometry, sourcePoint).distanceAlong), ) updates.push({ diff --git a/packages/core/src/systems/wall/wall-mitering.test.ts b/packages/core/src/systems/wall/wall-mitering.test.ts index cbe1b331d3..524d31cae4 100644 --- a/packages/core/src/systems/wall/wall-mitering.test.ts +++ b/packages/core/src/systems/wall/wall-mitering.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from 'bun:test' import type { WallNode } from '../../schema' +import { getWallCurveFrameAt } from './wall-curve' import { calculateLevelMiters, getWallMiterBoundaryPoints, pointToKey } from './wall-mitering' function wall(id: string, start: [number, number], end: [number, number]): WallNode { @@ -128,4 +129,24 @@ describe('junction grid prefilter', () => { const junction = calculateLevelMiters([long, spur]).junctions.get(pointToKey({ x: 60, y: 0 })) expect(junction?.connectedWalls.map((cw) => cw.wall.id)).toEqual(['spur', 'long']) }) + + test('finds a T-junction on a curved wall and uses its local tangent', () => { + const curved = { + ...wall('curved', [0, 0], [4, 0]), + curveOffset: 1, + } as WallNode + const midpoint = getWallCurveFrameAt(curved, 0.5).point + const spur = wall('spur', [midpoint.x, midpoint.y], [midpoint.x, midpoint.y + 3]) + const miter = calculateLevelMiters([curved, spur]) + const junction = miter.junctions.get(pointToKey(midpoint)) + + expect(junction?.connectedWalls.map((entry) => [entry.wall.id, entry.endType])).toEqual([ + ['spur', 'start'], + ['curved', 'passthrough'], + ]) + const boundary = getWallMiterBoundaryPoints(spur, miter) + expect(boundary).not.toBeNull() + expect(boundary?.startLeft.x).toBeCloseTo(midpoint.x - 0.05, 4) + expect(boundary?.startRight.x).toBeCloseTo(midpoint.x + 0.05, 4) + }) }) diff --git a/packages/core/src/systems/wall/wall-mitering.ts b/packages/core/src/systems/wall/wall-mitering.ts index b1fd5a6bd7..a982126caa 100644 --- a/packages/core/src/systems/wall/wall-mitering.ts +++ b/packages/core/src/systems/wall/wall-mitering.ts @@ -1,5 +1,11 @@ import type { WallNode } from '../../schema' -import { getWallCurveFrameAt, isCurvedWall } from './wall-curve' +import { getWallCurveFrameAt, isCurvedWall, projectPointToWallCenterline } from './wall-curve' +import { + findWallJunctions, + pointOnWallSegment, + pointToKey, + type WallJunction, +} from './wall-junctions' // ============================================================================ // TYPES @@ -33,8 +39,6 @@ type JunctionData = Map<string, WallIntersections> // UTILITY FUNCTIONS // ============================================================================ -const TOLERANCE = 0.001 - // Miter joints are line-line intersections, so the joint point sits a distance // ≈ halfThickness / sin(θ) from the junction, where θ is the angle between the // two walls. As θ → 0 (two walls nearly collinear — e.g. a room-preset preview @@ -46,11 +50,6 @@ const TOLERANCE = 0.001 // mitering down to ~11°) while bounding the pathological near-collinear case. const MITER_LIMIT = 10 -function pointToKey(p: Point2D, tolerance = TOLERANCE): string { - const snap = 1 / tolerance - return `${Math.round(p.x * snap)},${Math.round(p.y * snap)}` -} - function createLineFromPointAndVector(p: Point2D, v: Point2D): LineEquation { const a = -v.y const b = v.x @@ -58,178 +57,18 @@ function createLineFromPointAndVector(p: Point2D, v: Point2D): LineEquation { return { a, b, c } } -/** - * Checks if a point lies on a wall segment (not at its endpoints) - */ -function pointOnWallSegment(point: Point2D, wall: WallNode, tolerance = TOLERANCE): boolean { - const start: Point2D = { x: wall.start[0], y: wall.start[1] } - const end: Point2D = { x: wall.end[0], y: wall.end[1] } - - // Check if point is at endpoints (those are handled separately) - if (pointToKey(point, tolerance) === pointToKey(start, tolerance)) return false - if (pointToKey(point, tolerance) === pointToKey(end, tolerance)) return false - - // Vector from start to end - const v = { x: end.x - start.x, y: end.y - start.y } - const L = Math.sqrt(v.x * v.x + v.y * v.y) - if (L < 1e-9) return false - - // Vector from start to point - const w = { x: point.x - start.x, y: point.y - start.y } - - // Project point onto wall line (t is parametric position along segment) - const t = (v.x * w.x + v.y * w.y) / (L * L) - - // Check if projection is within segment (not at endpoints) - if (t < tolerance || t > 1 - tolerance) return false - - // Check distance from point to line - const projX = start.x + t * v.x - const projY = start.y + t * v.y - const dist = Math.sqrt((point.x - projX) ** 2 + (point.y - projY) ** 2) - - return dist < tolerance -} - -// ============================================================================ -// JUNCTION DETECTION (exactly like demo) -// ============================================================================ - -interface Junction { - meetingPoint: Point2D - connectedWalls: Array<{ wall: WallNode; endType: 'start' | 'end' | 'passthrough' }> -} - -// --- Uniform grid used to prefilter T-junction candidates -------------------- -// 2 m cells: small enough that a dense imported floor spreads across many -// buckets, large enough that an ordinary room wall touches only a few. -const JUNCTION_GRID_CELL = 2.0 -// A wall whose AABB would touch more than this many cells (a very long diagonal) -// is kept in a fallback list checked against every junction. Such walls are rare, -// and a model made only of them is a model with very few walls — where the naive -// scan was never the problem. -const JUNCTION_GRID_MAX_CELLS_PER_WALL = 64 - -function cellKey(x: number, y: number): string { - return `${Math.floor(x / JUNCTION_GRID_CELL)},${Math.floor(y / JUNCTION_GRID_CELL)}` -} +type Junction = WallJunction -function buildJunctionGrid(walls: WallNode[]): { - grid: Map<string, WallNode[]> - oversized: WallNode[] -} { - const grid = new Map<string, WallNode[]>() - const oversized: WallNode[] = [] - - for (const wall of walls) { - // Pad by TOLERANCE so a point sitting exactly on the AABB edge still lands - // in a covered cell. - const minX = Math.min(wall.start[0], wall.end[0]) - TOLERANCE - const maxX = Math.max(wall.start[0], wall.end[0]) + TOLERANCE - const minY = Math.min(wall.start[1], wall.end[1]) - TOLERANCE - const maxY = Math.max(wall.start[1], wall.end[1]) + TOLERANCE - - const cx0 = Math.floor(minX / JUNCTION_GRID_CELL) - const cx1 = Math.floor(maxX / JUNCTION_GRID_CELL) - const cy0 = Math.floor(minY / JUNCTION_GRID_CELL) - const cy1 = Math.floor(maxY / JUNCTION_GRID_CELL) - - if ((cx1 - cx0 + 1) * (cy1 - cy0 + 1) > JUNCTION_GRID_MAX_CELLS_PER_WALL) { - oversized.push(wall) - continue - } - - for (let cx = cx0; cx <= cx1; cx++) { - for (let cy = cy0; cy <= cy1; cy++) { - const key = `${cx},${cy}` - const bucket = grid.get(key) - if (bucket) bucket.push(wall) - else grid.set(key, [wall]) - } - } - } - - return { grid, oversized } -} - -function findJunctions(walls: WallNode[]): Map<string, Junction> { - const junctions = new Map<string, Junction>() - - // First pass: group walls by their endpoints - for (const wall of walls) { - const startPt: Point2D = { x: wall.start[0], y: wall.start[1] } - const endPt: Point2D = { x: wall.end[0], y: wall.end[1] } - - const keyStart = pointToKey(startPt) - const keyEnd = pointToKey(endPt) - - if (!junctions.has(keyStart)) { - junctions.set(keyStart, { meetingPoint: startPt, connectedWalls: [] }) - } - junctions.get(keyStart)?.connectedWalls.push({ wall, endType: 'start' }) - - if (!junctions.has(keyEnd)) { - junctions.set(keyEnd, { meetingPoint: endPt, connectedWalls: [] }) - } - junctions.get(keyEnd)?.connectedWalls.push({ wall, endType: 'end' }) - } - - // Second pass: detect T-junctions (walls passing through junction points). - // - // The naive form of this pass is `for each junction: for each wall` — O(J×N). - // On a real imported floor (1081 walls, 2047 endpoint keys) that is ~2.2M - // pointOnWallSegment calls and measured 584 ms per findJunctions() call, which - // WallSystem then repeats every frame while progressively rebuilding. - // - // A T-junction can only exist where the junction point lies ON the wall - // segment, so it must lie inside the wall's AABB. Bucketing walls by the grid - // cells their AABB covers therefore loses nothing: the cell containing the - // point is always one of the cells the wall was indexed into. With the input - // ordering restored below, the result matches the naive pass exactly; measured - // 11 ms on the same geometry. - const { grid, oversized } = buildJunctionGrid(walls) - const wallOrder = new Map(walls.map((wall, index) => [wall.id, index])) - for (const [_key, junction] of junctions.entries()) { - const p = junction.meetingPoint - const cellCandidates = grid.get(cellKey(p.x, p.y)) - const passthrough: WallNode[] = [] - for (const bucket of [cellCandidates, oversized]) { - if (!bucket || bucket.length === 0) continue - for (const wall of bucket) { - // Skip if wall already in this junction - if (junction.connectedWalls.some((cw) => cw.wall.id === wall.id)) continue - - // Check if junction point lies on this wall's segment (not at endpoints) - if (pointOnWallSegment(junction.meetingPoint, wall)) { - passthrough.push(wall) - } - } - } - - // Append in input order, not bucket order. Two collinear walls overlapping a - // junction tie on angle in `calculateJunctionIntersections`, so its stable - // sort leaves them in the order they were appended here — and an oversized - // wall would otherwise land after a shorter collinear neighbour it precedes - // in `walls`, picking the other wall's thickness for the miter. - passthrough.sort((a, b) => (wallOrder.get(a.id) ?? 0) - (wallOrder.get(b.id) ?? 0)) - for (const wall of passthrough) { - junction.connectedWalls.push({ wall, endType: 'passthrough' }) - } - } - - // Filter to only junctions with 2+ walls - const actualJunctions = new Map<string, Junction>() - for (const [key, junction] of junctions.entries()) { - if (junction.connectedWalls.length >= 2) { - actualJunctions.set(key, junction) - } - } - - return actualJunctions -} - -function getWallDirectionFromJunction(wall: WallNode, endType: 'start' | 'end' | 'passthrough') { +function getWallDirectionFromJunction( + wall: WallNode, + endType: 'start' | 'end' | 'passthrough', + meetingPoint?: Point2D, +) { if (endType === 'passthrough') { + if (isCurvedWall(wall) && meetingPoint) { + const projection = projectPointToWallCenterline(wall, meetingPoint) + if (projection) return projection.frame.tangent + } return { x: wall.end[0] - wall.start[0], y: wall.end[1] - wall.start[1], @@ -307,7 +146,8 @@ function calculateJunctionIntersections( if (endType === 'passthrough') { // For passthrough walls (T-junctions), add both directions // This allows walls meeting the middle of this wall to miter against it - const v1 = { x: wall.end[0] - wall.start[0], y: wall.end[1] - wall.start[1] } + const tangent = getWallDirectionFromJunction(wall, endType, meetingPoint) + const v1 = tangent const v2 = { x: -v1.x, y: -v1.y } for (const v of [v1, v2]) { @@ -439,7 +279,7 @@ export interface WallMiterData { */ export function calculateLevelMiters(walls: WallNode[]): WallMiterData { const getThickness = (wall: WallNode) => wall.thickness ?? 0.1 - const junctions = findJunctions(walls) + const junctions = findWallJunctions(walls) const junctionData: JunctionData = new Map() for (const [key, junction] of junctions.entries()) { diff --git a/packages/core/src/systems/wall/wall-topology.ts b/packages/core/src/systems/wall/wall-topology.ts index d97477a9ed..fe5c691cc5 100644 --- a/packages/core/src/systems/wall/wall-topology.ts +++ b/packages/core/src/systems/wall/wall-topology.ts @@ -1,20 +1,26 @@ import { GROUND_SUPPORT_ID } from '../../hooks/spatial-grid/support-host-id' import { terrainSupportLift } from '../../lib/terrain-support' +import { type AnyNode, type AnyNodeId, type WallNode, WallNode as WallSchema } from '../../schema' import { - type AnyNode, - type AnyNodeId, - type DoorNode, - getScaledDimensions, - type ItemNode, - type WallNode, - WallNode as WallSchema, - type WindowNode, -} from '../../schema' -import { getWallArcData, getWallCurveFrameAt, getWallCurveLength, isCurvedWall } from './wall-curve' + getWallAttachmentSpan, + getWallAttachments, + remapWallAttachment, + segmentCurveOffset, + wallLength, + wallPointAt, +} from './wall-attachments' +import { isCurvedWall } from './wall-curve' +import { + curvedSegmentIntersections, + distanceSquared, + joinCrossingAtNearbyWallEndpoint, + nearestWallProjection, + straightSegmentIntersection, + type WallSegmentIntersection, +} from './wall-intersections' import type { WallPlanPoint } from './wall-move' const WALL_MIN_LENGTH = 0.01 -const WALL_SPLIT_ENDPOINT_EPSILON = 0.02 const WALL_INTERSECTION_EPSILON = 1e-6 export type WallTopologyChanges = { @@ -47,19 +53,6 @@ export type WallPointSplitResult = | { ok: true; plan: WallPointSplitPlan } | { ok: false; reason: 'no-host' } -type WallSegmentIntersection = { - wallId: WallNode['id'] - point: WallPlanPoint - draftT: number - wallT: number -} - -function distanceSquared(a: WallPlanPoint, b: WallPlanPoint) { - const dx = a[0] - b[0] - const dz = a[1] - b[1] - return dx * dx + dz * dz -} - function isSegmentLongEnough(start: WallPlanPoint, end: WallPlanPoint) { return distanceSquared(start, end) >= WALL_MIN_LENGTH * WALL_MIN_LENGTH } @@ -140,59 +133,6 @@ export function uncoveredWallSegments( return segments } -function projectPointOntoWallCenterline( - point: WallPlanPoint, - wall: WallNode, -): { point: WallPlanPoint; wallT: number } | null { - if (isCurvedWall(wall)) { - const arc = getWallArcData(wall) - if (!arc) return null - const pointAngle = Math.atan2(point[1] - arc.center.y, point[0] - arc.center.x) - let directedAngle = (pointAngle - arc.startAngle) * arc.direction - while (directedAngle < 0) directedAngle += Math.PI * 2 - const wallT = directedAngle / Math.abs(arc.delta) - if (wallT <= 0 || wallT >= 1) return null - return { point: wallPointAt(wall, wallT), wallT } - } - - const dx = wall.end[0] - wall.start[0] - const dz = wall.end[1] - wall.start[1] - const lengthSquared = dx * dx + dz * dz - if (lengthSquared < 1e-9) return null - const wallT = ((point[0] - wall.start[0]) * dx + (point[1] - wall.start[1]) * dz) / lengthSquared - if (wallT <= 0 || wallT >= 1) return null - return { - point: [wall.start[0] + dx * wallT, wall.start[1] + dz * wallT], - wallT, - } -} - -function nearestWallProjection( - point: WallPlanPoint, - walls: WallNode[], - radius: number, - ignoreWallIds: ReadonlySet<string> = new Set(), -) { - let best: { wall: WallNode | null; point: WallPlanPoint; wallT: number } | null = null - let bestDistance = Number.POSITIVE_INFINITY - for (const wall of walls) { - if (ignoreWallIds.has(wall.id)) continue - const projection = projectPointOntoWallCenterline(point, wall) - if (!projection) continue - const candidateDistance = distanceSquared(point, projection.point) - if (candidateDistance > radius * radius || candidateDistance >= bestDistance) continue - const corner = ([wall.start, wall.end] as WallPlanPoint[]).find( - (candidate) => - distanceSquared(projection.point, candidate) <= - WALL_SPLIT_ENDPOINT_EPSILON * WALL_SPLIT_ENDPOINT_EPSILON, - ) - best = corner - ? { wall: null, point: [corner[0], corner[1]], wallT: projection.wallT } - : { wall, ...projection } - bestDistance = candidateDistance - } - return best -} export function planWallSplitAtPoint( nodes: Record<AnyNodeId, AnyNode>, @@ -241,171 +181,6 @@ export function planWallSplitAtPoint( } } -function straightSegmentIntersection( - start: WallPlanPoint, - end: WallPlanPoint, - wall: WallNode, -): WallSegmentIntersection | null { - const rx = end[0] - start[0] - const rz = end[1] - start[1] - const sx = wall.end[0] - wall.start[0] - const sz = wall.end[1] - wall.start[1] - const denominator = rx * sz - rz * sx - if (Math.abs(denominator) < 1e-9) return null - - const offsetX = wall.start[0] - start[0] - const offsetZ = wall.start[1] - start[1] - const draftT = (offsetX * sz - offsetZ * sx) / denominator - const wallT = (offsetX * rz - offsetZ * rx) / denominator - if (draftT <= 0 || draftT >= 1 || wallT < 0 || wallT > 1) return null - - return { - wallId: wall.id, - point: [start[0] + draftT * rx, start[1] + draftT * rz], - draftT, - wallT, - } -} - -function curvedSegmentIntersections( - start: WallPlanPoint, - end: WallPlanPoint, - wall: WallNode, -): WallSegmentIntersection[] { - const arc = getWallArcData(wall) - if (!arc) return [] - - const dx = end[0] - start[0] - const dz = end[1] - start[1] - const offsetX = start[0] - arc.center.x - const offsetZ = start[1] - arc.center.y - const a = dx * dx + dz * dz - if (a < 1e-12) return [] - - const b = 2 * (offsetX * dx + offsetZ * dz) - const c = offsetX * offsetX + offsetZ * offsetZ - arc.radius * arc.radius - const discriminant = b * b - 4 * a * c - if (discriminant < -1e-9) return [] - - const root = Math.sqrt(Math.max(0, discriminant)) - const results: WallSegmentIntersection[] = [] - for (const rawDraftT of [(-b - root) / (2 * a), (-b + root) / (2 * a)]) { - if (rawDraftT < -1e-9 || rawDraftT > 1 + 1e-9) continue - const point: WallPlanPoint = [start[0] + rawDraftT * dx, start[1] + rawDraftT * dz] - const angle = Math.atan2(point[1] - arc.center.y, point[0] - arc.center.x) - let directedAngle = (angle - arc.startAngle) * arc.direction - while (directedAngle < 0) directedAngle += Math.PI * 2 - const rawWallT = directedAngle / Math.abs(arc.delta) - if (rawWallT < -1e-9 || rawWallT > 1 + 1e-9) continue - if (results.some((candidate) => distanceSquared(candidate.point, point) < 1e-12)) continue - results.push({ - wallId: wall.id, - point, - draftT: Math.max(0, Math.min(1, rawDraftT)), - wallT: Math.max(0, Math.min(1, rawWallT)), - }) - } - return results -} - -function joinCrossingAtNearbyWallEndpoint( - crossing: WallSegmentIntersection, - walls: WallNode[], -): WallSegmentIntersection { - const wall = walls.find((candidate) => candidate.id === crossing.wallId) - if (!wall) return crossing - const endpointIndex = ([wall.start, wall.end] as WallPlanPoint[]).findIndex( - (endpoint) => - distanceSquared(crossing.point, endpoint) <= - WALL_SPLIT_ENDPOINT_EPSILON * WALL_SPLIT_ENDPOINT_EPSILON, - ) - if (endpointIndex < 0) return crossing - const endpoint = endpointIndex === 0 ? wall.start : wall.end - return { ...crossing, point: [endpoint[0], endpoint[1]], wallT: endpointIndex } -} - -function wallLength(wall: WallNode) { - return isCurvedWall(wall) - ? getWallCurveLength(wall) - : Math.hypot(wall.end[0] - wall.start[0], wall.end[1] - wall.start[1]) -} - -function wallPointAt(wall: WallNode, wallT: number): WallPlanPoint { - if (wallT <= WALL_INTERSECTION_EPSILON) return wall.start - if (wallT >= 1 - WALL_INTERSECTION_EPSILON) return wall.end - const frame = getWallCurveFrameAt(wall, wallT) - return [frame.point.x, frame.point.y] -} - -function segmentCurveOffset(wall: WallNode, startT: number, endT: number) { - const arc = getWallArcData(wall) - if (!arc) return wall.curveOffset - const angle = Math.abs(arc.delta) * (endT - startT) - return arc.direction * arc.radius * (1 - Math.cos(angle / 2)) -} - -function attachmentSpan(node: AnyNode): { min: number; max: number; center: number } | null { - if (node.type === 'door') { - const door = node as DoorNode - return { - min: door.position[0] - door.width / 2, - max: door.position[0] + door.width / 2, - center: door.position[0], - } - } - if (node.type === 'window') { - const window = node as WindowNode - return { - min: window.position[0] - window.width / 2, - max: window.position[0] + window.width / 2, - center: window.position[0], - } - } - if (node.type === 'item') { - const item = node as ItemNode - if (item.asset.attachTo !== 'wall' && item.asset.attachTo !== 'wall-side') return null - const [width] = getScaledDimensions(item) - return { - min: item.position[0] - width / 2, - max: item.position[0] + width / 2, - center: item.position[0], - } - } - return null -} - -function wallAttachments(wall: WallNode, nodes: Record<AnyNodeId, AnyNode>) { - const ids = new Set<AnyNodeId>((wall.children ?? []) as AnyNodeId[]) - for (const node of Object.values(nodes)) { - if ( - node.parentId === wall.id || - ('wallId' in node && typeof node.wallId === 'string' && node.wallId === wall.id) - ) { - ids.add(node.id) - } - } - return [...ids].flatMap((id) => { - const node = nodes[id] - return node ? [node] : [] - }) -} - -function remapAttachment( - node: AnyNode, - wall: WallNode, - nextLocalX: number, -): Partial<AnyNode> | null { - if (!(node.type === 'door' || node.type === 'window' || node.type === 'item')) return null - const nextLength = wallLength(wall) - const clampedX = Math.max(0, Math.min(nextLength, nextLocalX)) - return { - parentId: wall.id, - wallId: wall.id, - position: [clampedX, node.position[1], node.position[2]], - ...(node.type === 'item' ? { wallT: nextLength > 1e-6 ? clampedX / nextLength : 0 } : {}), - } as Partial<AnyNode> -} - function splitWall( wall: WallNode, splitParameters: number[], @@ -448,8 +223,8 @@ function splitWall( const totalLength = wallLength(wall) const segmentChildren = segments.map(() => [] as AnyNodeId[]) const updates: WallTopologyChanges['update'] = [] - for (const attachment of wallAttachments(wall, nodes)) { - const span = attachmentSpan(attachment) + for (const attachment of getWallAttachments(wall, nodes)) { + const span = getWallAttachmentSpan(attachment) if (!span) return null const segmentIndex = parameters.slice(0, -1).findIndex((startT, index) => { const endT = parameters[index + 1]! @@ -457,7 +232,7 @@ function splitWall( }) if (segmentIndex < 0) return null const segment = segments[segmentIndex]! - const update = remapAttachment( + const update = remapWallAttachment( attachment, segment, span.center - totalLength * parameters[segmentIndex]!, diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index fc56af0736..b13cbfbe88 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -78,7 +78,10 @@ import { import { createPortal } from 'react-dom' import { Vector3 } from 'three' import { useShallow } from 'zustand/react/shallow' +import { markToolCancelConsumed } from '../../hooks/use-keyboard' import { resolveCeilingPlanPointSnap } from '../../lib/ceiling-plan-snap' +import { constrainDraftPointToLength } from '../../lib/draft-length' +import { registerDrawingControls } from '../../lib/drawing-controls' import { alignFloorplanDraftPoint, buildFloorplanItemEntry, @@ -114,6 +117,7 @@ import { cn } from '../../lib/utils' import { snapBuildingLocalToWorldGrid } from '../../lib/world-grid-snap' import { subscribeNavigationSyncPose } from '../../store/navigation-sync-pose-store' import useAlignmentGuides from '../../store/use-alignment-guides' +import { useDraftLength } from '../../store/use-draft-length' import type { GuideUiState, NavigationSyncPose } from '../../store/use-editor' import useEditor, { isAngleSnapActive, @@ -5080,7 +5084,17 @@ export function FloorplanPanel({ const setDraftEnd = useCallback( (next: WallPlanPoint | null | ((prev: WallPlanPoint | null) => WallPlanPoint | null)) => { const store = useFloorplanDraftPreview.getState() - store.setWallDraftEnd(typeof next === 'function' ? next(store.wallDraftEnd) : next) + const pointerEnd = typeof next === 'function' ? next(store.wallDraftPointerEnd) : next + store.setWallDraftPointerEnd(pointerEnd) + store.setWallDraftEnd( + pointerEnd && store.wallDraftStart + ? constrainDraftPointToLength( + store.wallDraftStart, + pointerEnd, + useDraftLength.getState().length, + ) + : pointerEnd, + ) }, [], ) @@ -5109,6 +5123,22 @@ export function FloorplanPanel({ useEffect(() => { useFloorplanDraftPreview.getState().setWallDraftStart(draftStart) }, [draftStart]) + useEffect( + () => + useDraftLength.subscribe((state, previous) => { + if (state.length === previous.length) return + const preview = useFloorplanDraftPreview.getState() + if (!(preview.wallDraftStart && preview.wallDraftPointerEnd)) return + preview.setWallDraftEnd( + constrainDraftPointToLength( + preview.wallDraftStart, + preview.wallDraftPointerEnd, + state.length, + ), + ) + }), + [], + ) useEffect(() => { useFloorplanDraftPreview.getState().setFenceDraftStart(fenceDraftStart) }, [fenceDraftStart]) @@ -7886,6 +7916,7 @@ export function FloorplanPanel({ wallConstructionOptionsRef.current = undefined wallChainWallIdsRef.current = [] setDraftEnd(null) + useDraftLength.getState().clear() useSegmentDraftChain.getState().clear('wall') }, [setDraftEnd]) const clearFencePlacementDraft = useCallback(() => { @@ -7986,6 +8017,9 @@ export function FloorplanPanel({ useEffect(() => { const handleCancel = () => { + if (draftStart || fenceDraftStart || roofDraftStart || activePolygonDraftPoints.length > 0) { + markToolCancelConsumed() + } clearDraft() } @@ -7993,7 +8027,7 @@ export function FloorplanPanel({ return () => { emitter.off('tool:cancel', handleCancel) } - }, [clearDraft]) + }, [clearDraft, draftStart, fenceDraftStart, roofDraftStart, activePolygonDraftPoints.length]) const createZoneOnCurrentLevel = useCallback( (points: WallPlanPoint[]) => { @@ -9902,6 +9936,7 @@ export function FloorplanPanel({ } setDraftStart(nextStart) + useDraftLength.getState().clear() setDraftEnd(nextStart) setCursorPoint(nextStart) }, @@ -9914,6 +9949,94 @@ export function FloorplanPanel({ setCursorPoint, ], ) + useEffect(() => { + const register = registerDrawingControls + const unregister = [ + register('wall', '2d', { + finish: () => { + const { wallDraftStart, wallDraftEnd } = useFloorplanDraftPreview.getState() + if ( + !wallDraftStart || + !wallDraftEnd || + Math.hypot(wallDraftEnd[0] - wallDraftStart[0], wallDraftEnd[1] - wallDraftStart[1]) < + 0.01 + ) + return false + const before = useScene.getState().nodes + handleWallPlacementPoint(wallDraftEnd) + return useScene.getState().nodes !== before + }, + back: clearWallPlacementDraft, + afterFinish: () => { + if (useEditor.getState().viewMode !== 'split') return + const end = useFloorplanDraftPreview.getState().wallDraftEnd + if (end) handleWallPlacementPoint(end) + }, + }), + register('fence', '2d', { + back: clearFencePlacementDraft, + afterFinish: () => { + const next = useSegmentDraftChain.getState().fence + setFenceDraftStart(next) + setFenceDraftEnd(next) + }, + }), + register('roof', '2d', { + back: clearRoofPlacementDraft, + afterFinish: clearRoofPlacementDraft, + }), + register('slab', '2d', { + finish: () => { + if (slabDraftPoints.length < 3) return false + const before = useScene.getState().nodes + handleSlabPlacementConfirm() + return useScene.getState().nodes !== before + }, + back: () => { + if (slabDraftPoints.length <= 1) clearSlabPlacementDraft() + else setSlabDraftPoints((points) => points.slice(0, -1)) + }, + afterFinish: clearSlabPlacementDraft, + }), + register('ceiling', '2d', { + finish: () => { + if (ceilingDraftPoints.length < 3) return false + const before = useScene.getState().nodes + handleCeilingPlacementConfirm() + return useScene.getState().nodes !== before + }, + back: () => setCeilingDraftPoints((points) => points.slice(0, -1)), + afterFinish: clearCeilingPlacementDraft, + }), + register('zone', '2d', { + finish: () => { + if (zoneDraftPoints.length < 3) return false + const before = useScene.getState().nodes + handleZonePlacementConfirm() + return useScene.getState().nodes !== before + }, + back: () => setZoneDraftPoints((points) => points.slice(0, -1)), + }), + ] + return () => { + for (const cleanup of unregister) cleanup() + } + }, [ + clearWallPlacementDraft, + clearFencePlacementDraft, + clearRoofPlacementDraft, + clearSlabPlacementDraft, + clearCeilingPlacementDraft, + setFenceDraftEnd, + slabDraftPoints.length, + ceilingDraftPoints.length, + zoneDraftPoints.length, + handleSlabPlacementConfirm, + handleCeilingPlacementConfirm, + handleZonePlacementConfirm, + handleWallPlacementPoint, + ]) + const { getFloorplanHitIdAtPoint, getFloorplanSelectionIdsInBounds } = useFloorplanHitTesting({ sceneRef: floorplanSceneRef, }) diff --git a/packages/editor/src/components/editor/grid.tsx b/packages/editor/src/components/editor/grid.tsx index 3bdb0b6850..4bd9ac62dd 100644 --- a/packages/editor/src/components/editor/grid.tsx +++ b/packages/editor/src/components/editor/grid.tsx @@ -317,6 +317,7 @@ export const Grid = ({ // Orientation is driven imperatively in `useFrame` (horizontal by default, // tilted into the wall plane while placing on a wall), so no static rotation. <mesh + name="editor-grid" geometry={geometry} layers={GRID_LAYER} material={material} diff --git a/packages/editor/src/components/editor/use-floorplan-scene-data.ts b/packages/editor/src/components/editor/use-floorplan-scene-data.ts index 912f6c4250..d7d2f3a2d1 100644 --- a/packages/editor/src/components/editor/use-floorplan-scene-data.ts +++ b/packages/editor/src/components/editor/use-floorplan-scene-data.ts @@ -2,6 +2,7 @@ import { type AnyNode, + type AnyNodeId, type BuildingNode, type CeilingNode, type DoorNode, @@ -159,7 +160,7 @@ export function useFloorplanSceneData({ return nextWalls.flatMap((wall) => wall.children - .map((childId) => state.nodes[childId]) + .map((childId) => state.nodes[childId as AnyNodeId]) .filter((node): node is OpeningNode => node?.type === 'window' || node?.type === 'door'), ) }), diff --git a/packages/editor/src/components/tools/wall/wall-draft-length.test.ts b/packages/editor/src/components/tools/wall/wall-draft-length.test.ts new file mode 100644 index 0000000000..73a1168572 --- /dev/null +++ b/packages/editor/src/components/tools/wall/wall-draft-length.test.ts @@ -0,0 +1,64 @@ +import { afterEach, describe, expect, test } from 'bun:test' +import { LevelNode, useScene, WallNode } from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' +import { constrainDraftPointToLength } from '../../../lib/draft-length' +import { useDraftLength } from '../../../store/use-draft-length' +import { createWallOnCurrentLevel } from './wall-drafting' + +afterEach(() => useDraftLength.getState().clear()) + +describe('wall draft length', () => { + test('keeps an exact length while direction changes without inventing a direction', () => { + expect(constrainDraftPointToLength([1, 1], [1, 1], 3)).toEqual([1, 1]) + expect(constrainDraftPointToLength([2, 3], [5, 7], 2.5)).toEqual([3.5, 5]) + expect(constrainDraftPointToLength([2, 3], [-8, 3], 1.25)).toEqual([0.75, 3]) + }) + + test('stores only finite lengths of at least one centimetre', () => { + const draft = useDraftLength.getState() + draft.setLength(3) + for (const invalid of [0, -1, Number.NaN, Number.POSITIVE_INFINITY, 0.001]) { + draft.setLength(invalid) + expect(useDraftLength.getState().length).toBe(3) + } + draft.clear() + expect(useDraftLength.getState().length).toBeNull() + }) + + test('commits the preview length near a wall without shortening it, as one undo step', () => { + const initialScene = useScene.getState() + const initialSelection = useViewer.getState().selection + const level = LevelNode.parse({ id: 'level_length', children: ['wall_nearby'] }) + const nearby = WallNode.parse({ + id: 'wall_nearby', + parentId: level.id, + start: [3.78, -1], + end: [3.78, 1], + }) + try { + useScene.setState({ + nodes: { [level.id]: level, [nearby.id]: nearby }, + rootNodeIds: [level.id], + dirtyNodes: new Set(), + }) + useViewer.setState({ selection: { ...initialSelection, levelId: level.id } }) + useScene.temporal.getState().clear() + useScene.temporal.getState().resume() + const before = useScene.getState().nodes + useDraftLength.getState().setLength(3.75) + const previewEnd = constrainDraftPointToLength([0, 0], [3.78, 0], 3.75) + expect(useScene.getState().nodes).toBe(before) + const wall = createWallOnCurrentLevel([0, 0], [3.78, 0]) + expect(wall?.end).toEqual(previewEnd!) + expect(wall?.end[0]).toBe(3.75) + expect(useScene.getState().nodes[nearby.id]).toBe(nearby) + expect(useScene.temporal.getState().pastStates).toHaveLength(1) + useScene.temporal.getState().undo() + expect(useScene.getState().nodes).toEqual(before) + } finally { + useScene.setState(initialScene) + useViewer.setState({ selection: initialSelection }) + useScene.temporal.getState().clear() + } + }) +}) diff --git a/packages/editor/src/components/tools/wall/wall-drafting.ts b/packages/editor/src/components/tools/wall/wall-drafting.ts index d38285b04b..d082afe8bf 100644 --- a/packages/editor/src/components/tools/wall/wall-drafting.ts +++ b/packages/editor/src/components/tools/wall/wall-drafting.ts @@ -11,8 +11,10 @@ import { type WallNode, } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' +import { constrainDraftPointToLength } from '../../../lib/draft-length' import { sfxEmitter } from '../../../lib/sfx-bus' import { resolveSnapFlags } from '../../../lib/snapping-mode' +import { useDraftLength } from '../../../store/use-draft-length' import useEditor, { getActiveSnappingMode, isMagneticSnapActive } from '../../../store/use-editor' import { distanceSquared, @@ -217,7 +219,15 @@ export function createWallOnCurrentLevel( return null } - const joinRadius = isMagneticSnapActive() ? WALL_JOIN_SNAP_RADIUS : WALL_CONNECT_SNAP_RADIUS + const draftLength = useDraftLength.getState().length + end = constrainDraftPointToLength(start, end, draftLength) + // An explicit length must survive the topology planner's endpoint projection. + const joinRadius = + draftLength !== null + ? 1e-7 + : isMagneticSnapActive() + ? WALL_JOIN_SNAP_RADIUS + : WALL_CONNECT_SNAP_RADIUS return runAsSingleSceneHistoryStep(useScene, () => { const result = planWallInsertion(nodes, { diff --git a/packages/editor/src/components/ui/helpers/helper-manager.tsx b/packages/editor/src/components/ui/helpers/helper-manager.tsx index 2d5cc3f100..0683b96d49 100644 --- a/packages/editor/src/components/ui/helpers/helper-manager.tsx +++ b/packages/editor/src/components/ui/helpers/helper-manager.tsx @@ -21,6 +21,12 @@ import { } from '../../../lib/contextual-help' import { getContextualHelpNodeExtension } from '../../../lib/contextual-help-extension' import { continuationContextOf } from '../../../lib/continuation' +import { + getDrawingControlsRevision, + hasDrawingControls, + subscribeDrawingControls, +} from '../../../lib/drawing-controls' +import { getToolOverlayExtension } from '../../../lib/tool-overlay-extension' import { canDirectMoveNode, canDirectRotateNode } from '../../../lib/direct-manipulation' import type { ReshapeKind } from '../../../lib/interaction/scope' import { isFreshPlacementMetadata } from '../../../lib/placement-metadata' @@ -141,6 +147,11 @@ export function HelperManager() { const selectedIds = useViewer((s) => s.selection.selectedIds) const isMobile = useIsMobile() const modifiers = useActiveModifierKeys() + useSyncExternalStore( + subscribeDrawingControls, + getDrawingControlsRevision, + getDrawingControlsRevision, + ) const selectedNodes = useScene( useShallow((s) => selectedIds @@ -268,7 +279,13 @@ export function HelperManager() { return <ContextualHelperPanel hints={reshapingHints(scope.reshape)} snapContext={snapContext} /> } - if (movingNode) { + const isRegistryDrawing = + mode === 'build' && + hasDrawingControls(tool) && + scope.kind === 'placing' && + scope.driver === 'registry-tool' && + scope.nodeType === tool + if (movingNode && !isRegistryDrawing) { if (movingNode.type === 'building') return <BuildingHelper showRotate /> // A fresh placement (e.g. a positioned preset like a shelf) advertises its // once/repeat continuation, exactly like the GLB item tool — but an existing @@ -335,11 +352,13 @@ export function HelperManager() { if (tool) { const def = nodeRegistry.get(tool) const hints = def?.toolHints ?? [] - if (hints.length > 0 || snapContext || continuationContext) { + const overlay = getToolOverlayExtension(def)?.component + if (hints.length > 0 || snapContext || continuationContext || overlay) { return ( <RegisteredToolHelper continuationContext={continuationContext} hints={hints} + overlay={overlay} shiftPressed={modifiers.shift} snapContext={snapContext} /> diff --git a/packages/editor/src/components/ui/helpers/registered-tool-helper.tsx b/packages/editor/src/components/ui/helpers/registered-tool-helper.tsx index 85483fa208..0bf40a040d 100644 --- a/packages/editor/src/components/ui/helpers/registered-tool-helper.tsx +++ b/packages/editor/src/components/ui/helpers/registered-tool-helper.tsx @@ -1,10 +1,26 @@ -import type { ToolHint } from '@pascal-app/core' -import { useMemo, useSyncExternalStore } from 'react' +import type { LazyComponent, ToolHint } from '@pascal-app/core' +import { type ComponentType, lazy, Suspense, useMemo, useSyncExternalStore } from 'react' +import { hasDrawingControls } from '../../../lib/drawing-controls' import type { ContinuationContext } from '../../../lib/continuation' import type { SnapContext } from '../../../lib/snapping-mode' import useEditor from '../../../store/use-editor' import { ContextualHelperPanel } from './contextual-helper-panel' +const overlayCache = new WeakMap<LazyComponent, ComponentType>() + +function ToolOverlay({ loader }: { loader: LazyComponent }) { + let Overlay = overlayCache.get(loader) + if (!Overlay) { + Overlay = lazy(loader) + overlayCache.set(loader, Overlay) + } + return ( + <Suspense fallback={null}> + <Overlay /> + </Suspense> + ) +} + /** * Generic helper panel rendered from `def.toolHints` data. Matches the * visual styling of the hand-written `<WallHelper>` / `<ItemHelper>` / @@ -19,12 +35,17 @@ export function RegisteredToolHelper({ shiftPressed = false, snapContext = null, continuationContext = null, + overlay, }: { hints: ToolHint[] shiftPressed?: boolean snapContext?: SnapContext | null continuationContext?: ContinuationContext | null + overlay?: LazyComponent }) { + const drawingTool = useEditor((s) => + s.mode === 'build' && hasDrawingControls(s.tool) ? s.tool : null, + ) // Live vertex count of an in-progress polygon draft, so hints gated on a // minimum (e.g. "Finish" at ≥ 3) only appear once they're actually possible. const draftVertexCount = useEditor((s) => s.draftVertexCount) @@ -38,7 +59,8 @@ export function RegisteredToolHelper({ for (const unsubscribe of unsubscribers) unsubscribe() } }, - getSnapshot: () => hints.map((hint) => (hint.visible?.value() === false ? '0' : '1')).join(''), + getSnapshot: () => + hints.map((hint) => (hint.visible?.value() === false ? '0' : '1')).join(''), }), [hints], ) @@ -51,30 +73,40 @@ export function RegisteredToolHelper({ // registry renderer from duplicating stale/static versions. const visible = hints.filter( (hint) => + !(drawingTool && ['Enter', 'Backspace', 'Esc', 'Escape'].includes(hint.key)) && !(hint.key === 'Shift' && hint.label === 'Cycle snapping mode') && hint.visible?.value() !== false && (hint.minDraftVertices == null || draftVertexCount >= hint.minDraftVertices), ) - if (visible.length === 0 && !snapContext && !continuationContext) return null + if (visible.length === 0 && !snapContext && !continuationContext && !overlay) return null // Hints carrying a live-state `chip` render as mode chips next to the // snapping / continuation rows; the rest stay static key rows. const chipHints = visible.filter((hint) => hint.chip) const staticHints = visible.filter((hint) => !hint.chip) + if (drawingTool) + staticHints.push( + { key: 'Enter', label: 'Place / finish' }, + { key: 'Backspace', label: 'Remove last point' }, + { key: 'Esc', label: 'Cancel draft' }, + ) return ( - <ContextualHelperPanel - chipHints={chipHints} - hints={staticHints.map((hint) => { - // Shift is a per-kind bypass for opening / zone / duct placement ("Free - // place", "Free angle", …) — those flip to a bypassed state while held. - const isBypassHint = hint.key === 'Shift' - return { - keys: [hint.key], - label: shiftPressed && isBypassHint ? 'Guided constraints bypassed' : hint.label, - active: shiftPressed && isBypassHint, - } - })} - continuationContext={continuationContext} - snapContext={snapContext} - /> + <> + {overlay && <ToolOverlay loader={overlay} />} + <ContextualHelperPanel + chipHints={chipHints} + hints={staticHints.map((hint) => { + // Shift is a per-kind bypass for opening / zone / duct placement ("Free + // place", "Free angle", …) — those flip to a bypassed state while held. + const isBypassHint = hint.key === 'Shift' + return { + keys: [hint.key], + label: shiftPressed && isBypassHint ? 'Guided constraints bypassed' : hint.label, + active: shiftPressed && isBypassHint, + } + })} + continuationContext={continuationContext} + snapContext={snapContext} + /> + </> ) } diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx index 2cdbe70ec4..b3ea1148f7 100644 --- a/packages/editor/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx @@ -106,7 +106,7 @@ export const WallTreeNode = memo(function WallTreeNode({ depth={depth + 1} isLast={index === children.length - 1} key={childId} - nodeId={childId} + nodeId={childId as AnyNodeId} /> ))} </TreeNodeWrapper> diff --git a/packages/editor/src/hooks/use-drawing-controls.ts b/packages/editor/src/hooks/use-drawing-controls.ts new file mode 100644 index 0000000000..b1dfb19817 --- /dev/null +++ b/packages/editor/src/hooks/use-drawing-controls.ts @@ -0,0 +1,48 @@ +import { useEffect } from 'react' +import { hasDrawingControls, runDrawingControl } from '../lib/drawing-controls' +import useDeleteConfirmation from '../store/use-delete-confirmation' +import useEditor from '../store/use-editor' +import useInteractionScope from '../store/use-interaction-scope' + +export function useDrawingControls(disabled = false) { + useEffect(() => { + if (disabled) return + const onKeyDown = (event: KeyboardEvent) => { + const { mode, tool, viewMode, isFirstPersonMode, workspaceMode } = useEditor.getState() + if ( + mode !== 'build' || + isFirstPersonMode || + workspaceMode === 'studio' || + useDeleteConfirmation.getState().request || + !hasDrawingControls(tool) || + event.defaultPrevented || + event.isComposing || + event.metaKey || + event.ctrlKey || + event.altKey || + (event.key !== 'Enter' && event.key !== 'Backspace') + ) + return + if ( + event.target instanceof Element && + event.target.closest( + 'input, textarea, select, [contenteditable]:not([contenteditable="false"]), [role="dialog"], button, [role="button"]', + ) + ) + return + const scope = useInteractionScope.getState().scope + if ( + scope.kind !== 'idle' && + !(scope.kind === 'drafting' && scope.tool === tool) && + !(scope.kind === 'placing' && scope.driver === 'registry-tool' && scope.nodeType === tool) + ) + return + event.preventDefault() + event.stopImmediatePropagation() + if (event.repeat) return + runDrawingControl(tool, event.key === 'Enter' ? 'finish' : 'back', viewMode) + } + window.addEventListener('keydown', onKeyDown, true) + return () => window.removeEventListener('keydown', onKeyDown, true) + }, [disabled]) +} diff --git a/packages/editor/src/hooks/use-keyboard.ts b/packages/editor/src/hooks/use-keyboard.ts index ad9d6457e5..45450a1147 100644 --- a/packages/editor/src/hooks/use-keyboard.ts +++ b/packages/editor/src/hooks/use-keyboard.ts @@ -40,6 +40,7 @@ import useInteractionScope, { isInteractionSubtreeDraft, } from '../store/use-interaction-scope' import { groupCurrentSelection, ungroupCurrentSelection } from '../store/use-session-groups' +import { useDrawingControls } from './use-drawing-controls' // References (guide/scan) are selected via `useEditor.selectedReferenceId`, not // the viewer selection, so selection-based key arms (R/T rotate) need this @@ -231,6 +232,7 @@ export const useKeyboard = ({ isVersionPreviewMode?: boolean disabled?: boolean } = {}) => { + useDrawingControls(disabled || isVersionPreviewMode) useEffect(() => { if (disabled) { return diff --git a/packages/editor/src/index.tsx b/packages/editor/src/index.tsx index 8053318d2d..01a1f8d8c5 100644 --- a/packages/editor/src/index.tsx +++ b/packages/editor/src/index.tsx @@ -370,6 +370,9 @@ export { continuationContextOf, nextContinuation, } from './lib/continuation' +export { canDirectMoveNode } from './lib/direct-manipulation' +export { constrainDraftPointToLength } from './lib/draft-length' +export { registerDrawingControls, runDrawingControl } from './lib/drawing-controls' export { createEditorApi } from './lib/editor-api' export { clearStructuralElevationGuide, @@ -540,6 +543,7 @@ export type { ModelExportFormat, ModelExportOptions, } from './lib/model-export' +export { cyclePaintScope, type PaintHoverInfo, paintScopeLabel } from './lib/paint-scope' export { consumePlacementDragRelease } from './lib/placement-drag-release' export { addFreshPlacementMetadata, @@ -573,7 +577,7 @@ export { hasRoofFaceChildOverlap, type RoofWallHit, resolveRoofWallHit } from '. export type { SceneGraph } from './lib/scene' export { applySceneGraphToEditor } from './lib/scene' export { movementSfxStepKey } from './lib/sfx/movement-tick' -export { triggerSFX } from './lib/sfx-bus' +export { emitDeleteSFX, triggerSFX } from './lib/sfx-bus' export { playSFX, type SFXName, type SFXPlaybackOptions } from './lib/sfx-player' export { clearSlabSnapFeedback, @@ -586,6 +590,7 @@ export { type SlabPlanSnapResult, } from './lib/slab-plan-snap' export { + cycleSnappingModeIn, getSnappingModeLabel, resolveSnapFlags, type SnapContext, @@ -610,12 +615,20 @@ export { type SurfacePlanSnapResult, } from './lib/surface-plan-snap' export { + brushRadiusRange, + clipTerrainPatchToSite, + commitStroke, fieldExtentForSite, flattenSite, resetSiteTerrain, resolveFlattenTarget, sculptFieldForSite, + terrainPointInsideSite, } from './lib/terrain-sculpt' +export { + TOOL_OVERLAY_EXTENSION_KEY, + type ToolOverlayExtension, +} from './lib/tool-overlay-extension' export { exportSceneToUsdz, type UsdzExportOptions } from './lib/usdz-export' export { useLinearDisplay } from './lib/use-linear-display' // `cn` (twMerge + clsx) — used by kind-owned panels in `@pascal-app/ @@ -635,6 +648,7 @@ export { default as useAlignmentGuides } from './store/use-alignment-guides' export { default as useAudio } from './store/use-audio' export { type CameraHintAction, useCameraHintFocus } from './store/use-camera-hint-focus' export { type CommandAction, useCommandRegistry } from './store/use-command-registry' +export { useDraftLength } from './store/use-draft-length' export { DRAWING_TYPE_OPTIONS, default as useDrawingView, diff --git a/packages/editor/src/lib/draft-length.ts b/packages/editor/src/lib/draft-length.ts new file mode 100644 index 0000000000..b5a8e6a972 --- /dev/null +++ b/packages/editor/src/lib/draft-length.ts @@ -0,0 +1,13 @@ +import type { WallPlanPoint } from '@pascal-app/core' + +export function constrainDraftPointToLength( + start: WallPlanPoint, + point: WallPlanPoint, + length: number | null, +): WallPlanPoint { + const dx = point[0] - start[0] + const dz = point[1] - start[1] + const distance = Math.hypot(dx, dz) + if (length === null || distance < 1e-8) return point + return [start[0] + (dx * length) / distance, start[1] + (dz * length) / distance] +} diff --git a/packages/editor/src/lib/drawing-controls.test.ts b/packages/editor/src/lib/drawing-controls.test.ts new file mode 100644 index 0000000000..931bad880f --- /dev/null +++ b/packages/editor/src/lib/drawing-controls.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, test } from 'bun:test' +import { + hasDrawingControls, + registerDrawingControls, + runDrawingControl, + subscribeDrawingControls, +} from './drawing-controls' + +describe('drawing controls', () => { + test('discovers plugin-defined drawing tools through registration', () => { + const tool = 'fixture:route' + let notifications = 0 + const unsubscribe = subscribeDrawingControls(() => notifications++) + expect(hasDrawingControls(tool)).toBe(false) + const unregister = registerDrawingControls(tool, '3d', { + finish: () => true, + back: () => {}, + }) + expect(hasDrawingControls(tool)).toBe(true) + expect(runDrawingControl(tool, 'finish', '3d')).toBe(true) + unregister() + expect(hasDrawingControls(tool)).toBe(false) + expect(notifications).toBe(2) + unsubscribe() + }) + + test.each([ + '2d', + '3d', + 'split', + ] as const)('polygon finish creates once and clears the sibling in %s', (view) => { + const calls: string[] = [] + const cleanup = (['2d', '3d'] as const).map((surface) => + registerDrawingControls('slab', surface, { + finish: () => { + calls.push(`commit ${surface}`) + return true + }, + back: () => calls.push(`back ${surface}`), + afterFinish: () => calls.push(`clear ${surface}`), + }), + ) + try { + expect(runDrawingControl('slab', 'finish', view)).toBe(true) + expect(calls).toEqual(view === '2d' ? ['commit 2d', 'clear 3d'] : ['commit 3d', 'clear 2d']) + calls.length = 0 + runDrawingControl('slab', 'back', view) + expect(calls).toEqual(['back 2d', 'back 3d']) + } finally { + for (const off of cleanup) off() + } + }) + + test.each([ + ['fence', '3d'], + ['roof', '3d'], + ['zone', '2d'], + ] as const)('%s discovers its registered creation owner', (tool, owner) => { + const calls: string[] = [] + const cleanup = (['2d', '3d'] as const).map((view) => + registerDrawingControls(tool, view, { + ...(view === owner + ? { + finish: () => { + calls.push(view) + return true + }, + } + : {}), + back: () => {}, + }), + ) + try { + for (const view of ['2d', '3d', 'split'] as const) runDrawingControl(tool, 'finish', view) + expect(calls).toEqual(Array(3).fill(owner)) + } finally { + for (const off of cleanup) off() + } + }) + + test('invalid drafts stay intact and a failed preferred owner does not duplicate creation', () => { + const calls: string[] = [] + const off2d = registerDrawingControls('ceiling', '2d', { + finish: () => { + calls.push('commit') + return true + }, + back: () => {}, + afterFinish: () => calls.push('clear'), + }) + const off3d = registerDrawingControls('ceiling', '3d', { finish: () => false, back: () => {} }) + try { + expect(runDrawingControl('ceiling', 'finish', 'split')).toBe(false) + off3d() + expect(runDrawingControl('ceiling', 'finish', '3d')).toBe(true) + expect(calls).toEqual(['commit']) + } finally { + off2d() + off3d() + } + }) + + test('cleanup cannot remove a newer registration', () => { + const calls: string[] = [] + const old = registerDrawingControls('wall', '3d', { back: () => calls.push('old') }) + const current = registerDrawingControls('wall', '3d', { back: () => calls.push('current') }) + old() + runDrawingControl('wall', 'back', '3d') + expect(calls).toEqual(['current']) + current() + expect(runDrawingControl('wall', 'back', '3d')).toBe(false) + }) +}) diff --git a/packages/editor/src/lib/drawing-controls.ts b/packages/editor/src/lib/drawing-controls.ts new file mode 100644 index 0000000000..c2b5503102 --- /dev/null +++ b/packages/editor/src/lib/drawing-controls.ts @@ -0,0 +1,70 @@ +export type DrawingTool = string +type DrawingView = '2d' | '3d' +export type DrawingControls = { + finish?: () => boolean + back: () => void + afterFinish?: () => void +} + +const controls = new Map<DrawingTool, Map<DrawingView, DrawingControls>>() +const subscribers = new Set<() => void>() +let revision = 0 + +function notifySubscribers() { + revision += 1 + for (const subscriber of subscribers) subscriber() +} + +export function subscribeDrawingControls(subscriber: () => void) { + subscribers.add(subscriber) + return () => subscribers.delete(subscriber) +} + +export function getDrawingControlsRevision() { + return revision +} + +export function hasDrawingControls(tool: string | null): tool is DrawingTool { + return tool !== null && controls.has(tool) +} + +export function registerDrawingControls( + tool: DrawingTool, + view: DrawingView, + handlers: DrawingControls, +) { + let views = controls.get(tool) + if (!views) { + views = new Map() + controls.set(tool, views) + } + views.set(view, handlers) + notifySubscribers() + return () => { + if (views.get(view) !== handlers) return + views.delete(view) + if (views.size === 0 && controls.get(tool) === views) controls.delete(tool) + notifySubscribers() + } +} + +export function runDrawingControl( + tool: DrawingTool, + action: 'finish' | 'back', + view: DrawingView | 'split', +): boolean { + const views = controls.get(tool) + if (!views) return false + if (action === 'back') { + for (const handlers of views.values()) handlers.back() + return true + } + const preferred: DrawingView = view === '2d' ? '2d' : '3d' + const fallback: DrawingView = preferred === '2d' ? '3d' : '2d' + const owner = views.get(preferred)?.finish ? preferred : fallback + const primary = views.get(owner) + const secondary = views.get(owner === '2d' ? '3d' : '2d') + if (!primary?.finish?.()) return false + secondary?.afterFinish?.() + return true +} diff --git a/packages/editor/src/lib/tool-overlay-extension.ts b/packages/editor/src/lib/tool-overlay-extension.ts new file mode 100644 index 0000000000..63e5f55e19 --- /dev/null +++ b/packages/editor/src/lib/tool-overlay-extension.ts @@ -0,0 +1,13 @@ +import type { LazyComponent, NodeDefinition } from '@pascal-app/core' + +export const TOOL_OVERLAY_EXTENSION_KEY = 'pascal:editor/tool-overlay' + +export type ToolOverlayExtension = { + component: LazyComponent +} + +export function getToolOverlayExtension( + definition: NodeDefinition<any> | undefined, +): ToolOverlayExtension | undefined { + return definition?.extensions?.[TOOL_OVERLAY_EXTENSION_KEY] as ToolOverlayExtension | undefined +} diff --git a/packages/editor/src/store/use-draft-length.ts b/packages/editor/src/store/use-draft-length.ts new file mode 100644 index 0000000000..07507bbe5c --- /dev/null +++ b/packages/editor/src/store/use-draft-length.ts @@ -0,0 +1,17 @@ +import { create } from 'zustand' + +type DraftLengthState = { + length: number | null + setLength(length: number | null): void + clear(): void +} + +export const useDraftLength = create<DraftLengthState>((set) => ({ + length: null, + setLength: (length) => + set((state) => { + if (length !== null && (!Number.isFinite(length) || length < 0.01)) return state + return length === state.length ? state : { length } + }), + clear: () => set((state) => (state.length === null ? state : { length: null })), +})) diff --git a/packages/editor/src/store/use-floorplan-draft-preview.ts b/packages/editor/src/store/use-floorplan-draft-preview.ts index 30108ae02e..3ad4d91f83 100644 --- a/packages/editor/src/store/use-floorplan-draft-preview.ts +++ b/packages/editor/src/store/use-floorplan-draft-preview.ts @@ -20,6 +20,7 @@ type SvgPoint = { x: number; y: number } export type FloorplanPolygonDraftType = 'ceiling' | 'slab' | 'zone' type FloorplanDraftPreviewState = { + wallDraftPointerEnd: WallPlanPoint | null /** Snapped plan-XZ point under the cursor; drives the crosshair + the * cursor-following polygon-draft preview. `null` when idle. */ cursorPoint: WallPlanPoint | null @@ -53,6 +54,7 @@ type FloorplanDraftPreviewState = { /** Set the screen-space cursor point (deduped on x/y). */ setCursorPosition(point: SvgPoint | null): void setWallDraftEnd(point: WallPlanPoint | null): void + setWallDraftPointerEnd(point: WallPlanPoint | null): void setFenceDraftEnd(point: WallPlanPoint | null): void setRoofDraftEnd(point: WallPlanPoint | null): void setWallDraftStart(point: WallPlanPoint | null): void @@ -93,6 +95,7 @@ function setPlanPointField( } export const useFloorplanDraftPreview = create<FloorplanDraftPreviewState>((set) => ({ + wallDraftPointerEnd: null, cursorPoint: null, cursorPosition: null, wallDraftEnd: null, @@ -120,6 +123,13 @@ export const useFloorplanDraftPreview = create<FloorplanDraftPreviewState>((set) return { cursorPosition: point } }), setWallDraftEnd: (point) => set(setPlanPointField('wallDraftEnd', point)), + setWallDraftPointerEnd: (point) => + set((state) => { + const previous = state.wallDraftPointerEnd + if (!point && !previous) return state + if (point && previous && point[0] === previous[0] && point[1] === previous[1]) return state + return { wallDraftPointerEnd: point } + }), setFenceDraftEnd: (point) => set(setPlanPointField('fenceDraftEnd', point)), setRoofDraftEnd: (point) => set(setPlanPointField('roofDraftEnd', point)), setWallDraftStart: (point) => set(setPlanPointField('wallDraftStart', point)), @@ -141,6 +151,7 @@ export const useFloorplanDraftPreview = create<FloorplanDraftPreviewState>((set) state.cursorPoint === null && state.cursorPosition === null && state.wallDraftEnd === null && + state.wallDraftPointerEnd === null && state.fenceDraftEnd === null && state.roofDraftEnd === null && state.wallDraftStart === null && @@ -155,6 +166,7 @@ export const useFloorplanDraftPreview = create<FloorplanDraftPreviewState>((set) cursorPoint: null, cursorPosition: null, wallDraftEnd: null, + wallDraftPointerEnd: null, fenceDraftEnd: null, roofDraftEnd: null, wallDraftStart: null, diff --git a/packages/nodes/src/ceiling/draft-elevation.test.ts b/packages/nodes/src/ceiling/draft-elevation.test.ts new file mode 100644 index 0000000000..3b2931e244 --- /dev/null +++ b/packages/nodes/src/ceiling/draft-elevation.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, test } from 'bun:test' +import { BuildingNode, CeilingNode, LevelNode, SlabNode } from '@pascal-app/core' +import { resolveCeilingDraftElevation } from './draft-elevation' + +const polygon: Array<[number, number]> = [ + [0, 0], + [4, 0], + [4, 4], + [0, 4], +] + +function fixture() { + const building = BuildingNode.parse({ + id: 'building_draft', + children: ['level_lower', 'level_upper'], + }) + const lower = LevelNode.parse({ + id: 'level_lower', + parentId: building.id, + level: 0, + height: 3.2, + baseElevation: 1, + }) + const upper = LevelNode.parse({ + id: 'level_upper', + parentId: building.id, + level: 1, + height: 3, + children: ['slab_cover'], + }) + const slab = SlabNode.parse({ + id: 'slab_cover', + parentId: upper.id, + polygon, + elevation: 0, + thickness: 0.3, + }) + return { + lower, + upper, + nodes: Object.fromEntries([building, lower, upper, slab].map((node) => [node.id, node])), + } +} + +describe('ceiling draft elevation', () => { + test('follows the covering slab and adds the level base exactly once', () => { + const { lower, nodes } = fixture() + const draft = CeilingNode.parse({ parentId: lower.id, polygon }) + const result = resolveCeilingDraftElevation(draft, nodes) + expect(result.baseY).toBe(1) + expect(result.height).toBeCloseTo(2.89) + expect(result.elevation).toBeCloseTo(3.89) + }) + + test('keeps a lower preset height and clamps a preset above the available space', () => { + const { lower, nodes } = fixture() + expect( + resolveCeilingDraftElevation({ parentId: lower.id, polygon, height: 2 }, nodes).elevation, + ).toBe(3) + expect( + resolveCeilingDraftElevation({ parentId: lower.id, polygon, height: 8 }, nodes).height, + ).toBeCloseTo(2.89) + }) + + test('resolves an upper-floor draft independently of the pointed surface height', () => { + const { upper, nodes } = fixture() + const result = resolveCeilingDraftElevation({ parentId: upper.id, polygon }, nodes) + expect(result.baseY).toBeCloseTo(4.2) + expect(result.elevation).toBeCloseTo(7.19) + }) +}) diff --git a/packages/nodes/src/ceiling/draft-elevation.ts b/packages/nodes/src/ceiling/draft-elevation.ts new file mode 100644 index 0000000000..241aeebece --- /dev/null +++ b/packages/nodes/src/ceiling/draft-elevation.ts @@ -0,0 +1,20 @@ +import { + type AnyNode, + type AnyNodeId, + type CeilingNode, + getCeilingClampBound, + getLevelElevations, + resolveCeilingHeight, +} from '@pascal-app/core' + +export function resolveCeilingDraftElevation( + ceiling: Pick<CeilingNode, 'parentId' | 'polygon' | 'height'>, + nodes: Record<AnyNodeId, AnyNode>, +) { + const baseY = ceiling.parentId ? (getLevelElevations(nodes).get(ceiling.parentId)?.baseY ?? 0) : 0 + const bound = ceiling.parentId + ? getCeilingClampBound(ceiling.parentId, nodes, ceiling.polygon) + : Number.POSITIVE_INFINITY + const height = Math.min(resolveCeilingHeight(ceiling, nodes), bound) + return { baseY, height, elevation: baseY + height } +} diff --git a/packages/nodes/src/ceiling/tool.tsx b/packages/nodes/src/ceiling/tool.tsx index df6f85e562..f8a7fb2eae 100644 --- a/packages/nodes/src/ceiling/tool.tsx +++ b/packages/nodes/src/ceiling/tool.tsx @@ -16,6 +16,7 @@ import { isAngleSnapActive, isGridSnapActive, markToolCancelConsumed, + registerDrawingControls, resolveCeilingPlanPointSnap, triggerSFX, useEditor, @@ -25,17 +26,18 @@ import { useViewer } from '@pascal-app/viewer' import { useEffect, useMemo, useRef, useState } from 'react' import { BufferGeometry, DoubleSide, type Group, type Line, Shape, Vector3 } from 'three' import { mix, positionLocal } from 'three/tsl' +import { useShallow } from 'zustand/react/shallow' +import { resolveCeilingDraftElevation } from './draft-elevation' import { CeilingNode } from './schema' /** * Phase 5 Stage D — ceiling placement tool (kind-owned via `def.tool`). * - * Multi-click polygon drawing at the ceiling height (2.52m default) + * Multi-click polygon drawing at the resolved ceiling height * with a vertical TSL-gradient connector + ground-shadow lines so the * draft is visible against both the ceiling plane and the floor. */ -const CEILING_HEIGHT = 2.52 const GRID_OFFSET = 0.02 function commitCeilingDrawing(levelId: LevelNode['id'], points: Array<[number, number]>): string { @@ -65,7 +67,19 @@ export const CeilingTool: React.FC = () => { const [points, setPoints] = useState<Array<[number, number]>>([]) const [cursorPosition, setCursorPosition] = useState<[number, number]>([0, 0]) const [snappedCursorPosition, setSnappedCursorPosition] = useState<[number, number]>([0, 0]) - const [levelY, setLevelY] = useState(0) + const ceilingDefaults = useEditor((state) => state.toolDefaults.ceiling) + const { baseY: levelY, height: ceilingHeight } = useScene( + useShallow((state) => + resolveCeilingDraftElevation( + { + parentId: currentLevelId, + polygon: [...points, snappedCursorPosition], + height: typeof ceilingDefaults?.height === 'number' ? ceilingDefaults.height : undefined, + }, + state.nodes, + ), + ), + ) const previousSnappedPointRef = useRef<[number, number] | null>(null) // Clear preset-seeded defaults on deactivation so a later manual ceiling @@ -98,16 +112,24 @@ export const CeilingTool: React.FC = () => { () => new BufferGeometry().setFromPoints([ new Vector3(0, 0, 0), - new Vector3(0, CEILING_HEIGHT - GRID_OFFSET, 0), + new Vector3(0, ceilingHeight - GRID_OFFSET, 0), ]), - [], + [ceilingHeight], ) + useEffect(() => () => verticalGeo.dispose(), [verticalGeo]) + const gradientOpacityNode = useMemo( - () => mix(0.6, 0.0, positionLocal.y.div(CEILING_HEIGHT - GRID_OFFSET).clamp()), - [], + () => mix(0.6, 0.0, positionLocal.y.div(Math.max(0.001, ceilingHeight - GRID_OFFSET)).clamp()), + [ceilingHeight], ) + useEffect(() => { + if (cursorRef.current) cursorRef.current.position.y = levelY + ceilingHeight + if (gridCursorRef.current) gridCursorRef.current.position.y = levelY + GRID_OFFSET + if (verticalLineRef.current) verticalLineRef.current.position.y = levelY + GRID_OFFSET + }, [levelY, ceilingHeight]) + useEffect(() => { if (!currentLevelId) return @@ -119,9 +141,8 @@ export const CeilingTool: React.FC = () => { const gridStep = isGridSnapActive() ? useEditor.getState().gridSnapStep : 0 const gridPosition: [number, number] = [...snapPointToGrid(rawPoint, gridStep)] setCursorPosition(gridPosition) - setLevelY(event.localPosition[1]) - const ceilingY = event.localPosition[1] + CEILING_HEIGHT - const gridY = event.localPosition[1] + GRID_OFFSET + const ceilingY = levelY + ceilingHeight + const gridY = levelY + GRID_OFFSET const lastPoint = points[points.length - 1] const orthoPoint: [number, number] = isAngleSnapActive() && lastPoint @@ -160,8 +181,10 @@ export const CeilingTool: React.FC = () => { Math.abs(clickPoint[0] - firstPoint[0]) < 0.25 && Math.abs(clickPoint[1] - firstPoint[1]) < 0.25 ) { - const ceilingId = commitCeilingDrawing(currentLevelId, points) - setSelection({ selectedIds: [ceilingId] }) + if (useEditor.getState().viewMode !== '2d') { + const ceilingId = commitCeilingDrawing(currentLevelId, points) + setSelection({ selectedIds: [ceilingId] }) + } setPoints([]) clearCeilingSnapFeedback() } else { @@ -175,8 +198,10 @@ export const CeilingTool: React.FC = () => { const onGridDoubleClick = (_event: GridEvent) => { if (!currentLevelId) return if (points.length >= 3) { - const ceilingId = commitCeilingDrawing(currentLevelId, points) - setSelection({ selectedIds: [ceilingId] }) + if (useEditor.getState().viewMode !== '2d') { + const ceilingId = commitCeilingDrawing(currentLevelId, points) + setSelection({ selectedIds: [ceilingId] }) + } setPoints([]) clearCeilingSnapFeedback() } @@ -188,18 +213,39 @@ export const CeilingTool: React.FC = () => { clearCeilingSnapFeedback() } + const resetDraft = () => { + setPoints([]) + previousSnappedPointRef.current = null + clearCeilingSnapFeedback() + } + const unregisterControls = registerDrawingControls('ceiling', '3d', { + finish: () => { + if (points.length < 3) return false + const ceilingId = commitCeilingDrawing(currentLevelId, points) + setSelection({ selectedIds: [ceilingId] }) + resetDraft() + return true + }, + back: () => { + if (points.length <= 1) resetDraft() + else setPoints(points.slice(0, -1)) + }, + afterFinish: resetDraft, + }) + emitter.on('grid:move', onGridMove) emitter.on('grid:click', onGridClick) emitter.on('grid:double-click', onGridDoubleClick) emitter.on('tool:cancel', onCancel) return () => { + unregisterControls() emitter.off('grid:move', onGridMove) emitter.off('grid:click', onGridClick) emitter.off('grid:double-click', onGridDoubleClick) emitter.off('tool:cancel', onCancel) } - }, [currentLevelId, points, cursorPosition, setSelection]) + }, [currentLevelId, points, cursorPosition, setSelection, levelY, ceilingHeight]) useEffect(() => { if (!(mainLineRef.current && closingLineRef.current)) return @@ -210,7 +256,7 @@ export const CeilingTool: React.FC = () => { if (groundClosingLineRef.current) groundClosingLineRef.current.visible = false return } - const ceilingY = levelY + CEILING_HEIGHT + const ceilingY = levelY + ceilingHeight const snappedCursor = snappedCursorPosition const linePoints: Vector3[] = points.map(([x, z]) => new Vector3(x, ceilingY, z)) linePoints.push(new Vector3(snappedCursor[0], ceilingY, snappedCursor[1])) @@ -250,7 +296,7 @@ export const CeilingTool: React.FC = () => { closingLineRef.current.visible = false groundClosingLineRef.current.visible = false } - }, [points, snappedCursorPosition, levelY]) + }, [points, snappedCursorPosition, levelY, ceilingHeight]) const previewShape = useMemo(() => { if (points.length < 3) return null @@ -301,7 +347,7 @@ export const CeilingTool: React.FC = () => { <mesh frustumCulled={false} layers={EDITOR_LAYER} - position={[0, levelY + CEILING_HEIGHT, 0]} + position={[0, levelY + ceilingHeight, 0]} rotation={[-Math.PI / 2, 0, 0]} > <shapeGeometry args={[previewShape]} /> @@ -404,7 +450,7 @@ export const CeilingTool: React.FC = () => { <CursorSphere color="#818cf8" key={index} - position={[x, levelY + CEILING_HEIGHT + 0.01, z]} + position={[x, levelY + ceilingHeight + 0.01, z]} showTooltip={false} /> ))} diff --git a/packages/nodes/src/door/renderer.test.tsx b/packages/nodes/src/door/renderer.test.tsx new file mode 100644 index 0000000000..e0fd849564 --- /dev/null +++ b/packages/nodes/src/door/renderer.test.tsx @@ -0,0 +1,31 @@ +import { expect, test } from 'bun:test' +import { DoorNode, sceneRegistry, useLiveNodeOverrides, useScene } from '@pascal-app/core' +import { act, create } from '@react-three/test-renderer' +import DoorRenderer from './renderer' + +test('door pose follows live overrides without changing scene data and resets on cancel', async () => { + const node = DoorNode.parse({ position: [1, 1.05, 0] }) + const originalNodes = useScene.getState().nodes + useScene.setState({ nodes: { [node.id]: node } }) + const renderer = await create(<DoorRenderer node={node} />) + try { + const storedNodes = useScene.getState().nodes + await act(async () => { + useLiveNodeOverrides.getState().set(node.id, { + position: [3, 1.05, 0], + rotation: [0, Math.PI, 0], + }) + }) + expect(useScene.getState().nodes).toBe(storedNodes) + expect(sceneRegistry.nodes.get(node.id)?.position.x).toBe(3) + expect(sceneRegistry.nodes.get(node.id)?.rotation.y).toBeCloseTo(Math.PI) + + await act(async () => useLiveNodeOverrides.getState().clear(node.id)) + expect(sceneRegistry.nodes.get(node.id)?.position.x).toBe(1) + expect(sceneRegistry.nodes.get(node.id)?.rotation.y).toBe(0) + } finally { + await renderer.unmount() + useLiveNodeOverrides.getState().clear(node.id) + useScene.setState({ nodes: originalNodes }) + } +}) diff --git a/packages/nodes/src/door/renderer.tsx b/packages/nodes/src/door/renderer.tsx index 48feda13ab..a393802b8d 100644 --- a/packages/nodes/src/door/renderer.tsx +++ b/packages/nodes/src/door/renderer.tsx @@ -16,30 +16,28 @@ export const DoorRenderer = ({ node }: { node: DoorNode }) => { useScene.getState().markDirty(node.id) }, [node.id]) const handlers = useNodeEvents(node, 'door') - const liveVisible = useLiveNodeOverrides((s) => { - const visible = s.get(node.id)?.visible - return typeof visible === 'boolean' ? visible : undefined - }) + const liveOverrides = useLiveNodeOverrides((s) => s.get(node.id)) + const renderNode = liveOverrides ? ({ ...node, ...liveOverrides } as DoorNode) : node const isTransient = !!(node.metadata as Record<string, unknown> | null)?.isTransient const mesh = ( <mesh castShadow material={doorHitboxMaterial} - position={node.position} + position={renderNode.position} receiveShadow ref={ref} - rotation={node.rotation} - visible={liveVisible ?? node.visible} + rotation={renderNode.rotation} + visible={renderNode.visible} {...(isTransient ? {} : handlers)} > <boxGeometry args={[0, 0, 0]} /> </mesh> ) - if (!node.roofSegmentId) return mesh + if (!renderNode.roofSegmentId) return mesh return ( - <RoofFaceHostFrame roofFace={node.roofFace} roofSegmentId={node.roofSegmentId}> + <RoofFaceHostFrame roofFace={renderNode.roofFace} roofSegmentId={renderNode.roofSegmentId}> {mesh} </RoofFaceHostFrame> ) diff --git a/packages/nodes/src/door/tool.tsx b/packages/nodes/src/door/tool.tsx index df186ae50d..34b33b8885 100644 --- a/packages/nodes/src/door/tool.tsx +++ b/packages/nodes/src/door/tool.tsx @@ -4,12 +4,14 @@ import { DoorNode, emitter, type GridEvent, + getEffectiveNode, holdHiddenWallPointerEvents, isCurvedWall, type RoofEvent, type RoofNode, sceneRegistry, spatialGridManager, + useLiveNodeOverrides, useScene, type WallEvent, type WallNode, @@ -134,7 +136,8 @@ const DoorTool: React.FC = () => { const live = useScene.getState().nodes[draft.id as AnyNodeId] if (live?.type !== 'door') return draftRef.current = live - publishPlacementPreview(live, parentNode) + publishPlacementPreview(getEffectiveNode(live), parentNode) + useScene.getState().markDirty(live.id) } let hostKind: HostKind = null @@ -180,6 +183,7 @@ const DoorTool: React.FC = () => { return } const wallId = draft.parentId + useLiveNodeOverrides.getState().clear(draft.id) useScene.getState().deleteNode(draft.id) draftRef.current = null clearPlacementPreview() @@ -341,13 +345,14 @@ const DoorTool: React.FC = () => { ) if (wall.id === draftRef.current.parentId) { - useScene.getState().updateNode(draftRef.current.id, { + useLiveNodeOverrides.getState().set(draftRef.current.id, { position: [clampedX, clampedY, 0], rotation: [0, itemRotation, 0], side, }) markHostDirty(wall.id) } else { + useLiveNodeOverrides.getState().clear(draftRef.current.id) useScene.getState().updateNode(draftRef.current.id, { position: [clampedX, clampedY, 0], rotation: [0, itemRotation, 0], @@ -405,6 +410,7 @@ const DoorTool: React.FC = () => { draftRef.current = null hostKind = null + useLiveNodeOverrides.getState().clear(draft.id) useScene.getState().deleteNode(draft.id) useScene.temporal.getState().resume() @@ -588,7 +594,7 @@ const DoorTool: React.FC = () => { if (draftRef.current && draftRef.current.parentId !== segment.id) destroyDraft() if (draftRef.current) { - useScene.getState().updateNode(draftRef.current.id, { + useLiveNodeOverrides.getState().set(draftRef.current.id, { position, rotation: [0, 0, 0], roofFace: face.id, @@ -626,6 +632,7 @@ const DoorTool: React.FC = () => { draftRef.current = null hostKind = null + useLiveNodeOverrides.getState().clear(draft.id) useScene.getState().deleteNode(draft.id) useScene.temporal.getState().resume() diff --git a/packages/nodes/src/fence/tool.tsx b/packages/nodes/src/fence/tool.tsx index f6a09f9823..686a7a2939 100644 --- a/packages/nodes/src/fence/tool.tsx +++ b/packages/nodes/src/fence/tool.tsx @@ -37,6 +37,7 @@ import { markToolCancelConsumed, type PointerSupportSurface, publishPlacementSurface, + registerDrawingControls, resolvePointerSupportSurface, type SegmentAngleReference, snapFenceDraftPoint, @@ -491,6 +492,7 @@ const StraightFenceTool: React.FC = () => { useEffect(() => () => useEditor.getState().setToolDefaults('fence', null), []) useEffect(() => { + let lastGridEvent: GridEvent | null = null let previousFenceEnd: FencePlanPoint | null = null // Alignment candidates — anchors of every alignable object. Refreshed @@ -538,6 +540,7 @@ const StraightFenceTool: React.FC = () => { } const onGridMove = (event: GridEvent) => { + lastGridEvent = event if (!(cursorRef.current && previewRef.current)) return // Ride the grid event plane on the pointed surface: aiming at an // elevated deck lifts the plane to the deck top, so the draft's XZ @@ -634,9 +637,9 @@ const StraightFenceTool: React.FC = () => { } } - const onGridClick = (event: GridEvent) => { + const onGridClick = (event: GridEvent, previewEnd?: FencePlanPoint) => { if (!previewRef.current) return - if (buildingState.current === 1 && event.nativeEvent.detail >= 2) { + if (!previewEnd && buildingState.current === 1 && event.nativeEvent.detail >= 2) { stopDrafting() return } @@ -676,17 +679,19 @@ const StraightFenceTool: React.FC = () => { }) } else { const angleLocked = isAngleSnapActive() - const snappedEnd = alignPoint( - snapFenceDraftPoint({ - point: localClick, - walls, - fences, - start: angleLocked ? [startingPoint.current.x, startingPoint.current.z] : undefined, - angleSnap: angleLocked, - magnetic: isMagneticSnapActive(), - }), - { applySnap: !angleLocked }, - ) + const snappedEnd = + previewEnd ?? + alignPoint( + snapFenceDraftPoint({ + point: localClick, + walls, + fences, + start: angleLocked ? [startingPoint.current.x, startingPoint.current.z] : undefined, + angleSnap: angleLocked, + magnetic: isMagneticSnapActive(), + }), + { applySnap: !angleLocked }, + ) const dx = snappedEnd[0] - startingPoint.current.x const dz = snappedEnd[1] - startingPoint.current.z if (dx * dx + dz * dz < 0.01 * 0.01) return @@ -749,11 +754,22 @@ const StraightFenceTool: React.FC = () => { } } + const unregisterControls = registerDrawingControls('fence', '3d', { + finish: () => { + if (buildingState.current !== 1 || !lastGridEvent) return false + const before = useScene.getState().nodes + onGridClick(lastGridEvent, [endingPoint.current.x, endingPoint.current.z]) + return useScene.getState().nodes !== before + }, + back: stopDrafting, + }) + emitter.on('grid:move', onGridMove) emitter.on('grid:click', onGridClick) emitter.on('tool:cancel', onCancel) return () => { + unregisterControls() emitter.off('grid:move', onGridMove) emitter.off('grid:click', onGridClick) emitter.off('tool:cancel', onCancel) @@ -850,27 +866,28 @@ const SplineFenceDraft: React.FC = () => { return [snapScalarToGrid(local[0], step), snapScalarToGrid(local[1], step)] } - const commit = () => { - const points = draftRef.current - if (points.length >= 2) { - const created = createSplineFenceOnCurrentLevel(points, undefined, { - supportCap: supportSurfaceRef.current?.elevation ?? null, - preferredSupportSlabId: supportSurfaceRef.current?.supportSlabId ?? null, - constructionElevation: supportSurfaceRef.current?.sourceNodeId - ? supportSurfaceRef.current.elevation - : null, - }) - if (created) { - triggerSFX('sfx:item-place') - // Once the new curve fence is selected for direct editing, leave - // placement mode so the toolbar matches the active interaction. - useViewer.getState().setSelection({ selectedIds: [created.id] }) - useEditor.getState().setTool(null) - useEditor.getState().setMode('select') - } - } + const resetDraft = () => { + draftRef.current = [] setDraftPoints([]) setCursor(null) + supportSurfaceRef.current = null + clearPlacementSurface() + } + const commit = () => { + const points = draftRef.current + if (points.length < 2) return false + const created = createSplineFenceOnCurrentLevel(points, undefined, { + supportCap: supportSurfaceRef.current?.elevation ?? null, + preferredSupportSlabId: supportSurfaceRef.current?.supportSlabId ?? null, + constructionElevation: supportSurfaceRef.current?.sourceNodeId + ? supportSurfaceRef.current.elevation + : null, + }) + if (!created) return false + triggerSFX('sfx:item-place') + useViewer.getState().setSelection({ selectedIds: [created.id] }) + resetDraft() + return true } const trackPointedSurface = (event: GridEvent) => { @@ -910,26 +927,32 @@ const SplineFenceDraft: React.FC = () => { setDraftPoints((prev) => [...prev, point]) } - const onKeyDown = (event: KeyboardEvent) => { - if (event.key === 'Enter') commit() - } const onCancel = () => { if (draftRef.current.length === 0) return markToolCancelConsumed() - setDraftPoints((prev) => prev.slice(0, -1)) + resetDraft() } + const unregisterControls = registerDrawingControls('fence', '3d', { + finish: commit, + back: () => { + if (draftRef.current.length <= 1) resetDraft() + else { + draftRef.current = draftRef.current.slice(0, -1) + setDraftPoints(draftRef.current) + } + }, + }) emitter.on('grid:move', onMove) emitter.on('grid:click', onClick) emitter.on('tool:cancel', onCancel) - window.addEventListener('keydown', onKeyDown) return () => { emitter.off('grid:move', onMove) emitter.off('grid:click', onClick) emitter.off('tool:cancel', onCancel) clearPlacementSurface() - window.removeEventListener('keydown', onKeyDown) + unregisterControls() } }, []) diff --git a/packages/nodes/src/roof/tool.tsx b/packages/nodes/src/roof/tool.tsx index b21780b2ca..75b4a26bd6 100644 --- a/packages/nodes/src/roof/tool.tsx +++ b/packages/nodes/src/roof/tool.tsx @@ -33,6 +33,7 @@ import { isGridSnapActive, isMagneticSnapActive, markToolCancelConsumed, + registerDrawingControls, resolveSurfacePlanPointSnap, snapWorldXZForActiveBuilding, triggerSFX, @@ -781,7 +782,9 @@ export const RoofTool: React.FC = () => { outlineRef.current.visible = true } + let lastGridEvent: GridEvent | null = null const onGridMove = (event: GridEvent) => { + lastGridEvent = event if (!cursorRef.current) return if (footprintSource !== 'draw') { @@ -842,7 +845,7 @@ export const RoofTool: React.FC = () => { } } - const onGridClick = (event: GridEvent) => { + const onGridClick = (event: GridEvent, previewEnd?: [number, number]) => { if (!currentLevelId) return if (footprintSource !== 'draw') { @@ -860,7 +863,7 @@ export const RoofTool: React.FC = () => { return } - const [gridX, gridZ] = resolveDraftPoint(event) + const [gridX, gridZ] = previewEnd ?? resolveDraftPoint(event) const y = event.localPosition[1] if (corner1Ref.current) { @@ -879,6 +882,7 @@ export const RoofTool: React.FC = () => { selectNode(roofId as AnyNode['id']) corner1Ref.current = null + setPreview((prev) => ({ ...prev, corner1: null })) const draftPreview = useFloorplanDraftPreview.getState() draftPreview.setRoofDraftStart(null) draftPreview.setRoofDraftEnd(null) @@ -957,6 +961,19 @@ export const RoofTool: React.FC = () => { triggerSFX('sfx:item-rotate') } + const unregisterControls = registerDrawingControls('roof', '3d', { + finish: () => { + const end = useFloorplanDraftPreview.getState().roofDraftEnd + const start = corner1Ref.current + if (footprintSource !== 'draw' || !start || !end || !lastGridEvent) return false + if (Math.abs(end[0] - start[0]) <= 0.1 || Math.abs(end[1] - start[2]) <= 0.1) return false + const before = sceneApi.nodes() + onGridClick(lastGridEvent, end) + return sceneApi.nodes() !== before + }, + back: onCancel, + }) + emitter.on('grid:move', onGridMove) emitter.on('grid:click', onGridClick) emitter.on('tool:cancel', onCancel) @@ -995,6 +1012,7 @@ export const RoofTool: React.FC = () => { window.addEventListener('keydown', onKeyDown) return () => { + unregisterControls() emitter.off('grid:move', onGridMove) emitter.off('grid:click', onGridClick) emitter.off('tool:cancel', onCancel) diff --git a/packages/nodes/src/shared/opening-width-affordances.test.ts b/packages/nodes/src/shared/opening-width-affordances.test.ts index 6325841db5..338a29dc1a 100644 --- a/packages/nodes/src/shared/opening-width-affordances.test.ts +++ b/packages/nodes/src/shared/opening-width-affordances.test.ts @@ -28,27 +28,30 @@ afterEach(() => { describe('opening width floor-plan affordances', () => { for (const kind of ['door', 'window'] as const) { test(`${kind} previews through a live override and writes the scene only on commit`, () => { - const wall = WallNode.parse({ - id: `wall_${kind}`, - start: [0, 0], - end: [6, 0], - }) + const wallId = `wall_${kind}` + const openingId = `${kind}_width-live` const opening = kind === 'door' ? DoorNode.parse({ - id: 'door_width-live', - parentId: wall.id, - wallId: wall.id, + id: openingId, + parentId: wallId, + wallId, position: [2, 1.05, 0], width: 1, }) : WindowNode.parse({ - id: 'window_width-live', - parentId: wall.id, - wallId: wall.id, + id: openingId, + parentId: wallId, + wallId, position: [2, 1.05, 0], width: 1, }) + const wall = WallNode.parse({ + id: wallId, + children: [opening.id], + start: [0, 0], + end: [6, 0], + }) const nodes = { [wall.id]: wall, [opening.id]: opening } useScene.setState({ nodes } as never) const affordance = kind === 'door' ? doorWidthAffordance : windowWidthAffordance diff --git a/packages/nodes/src/shared/wall-attach-target.ts b/packages/nodes/src/shared/wall-attach-target.ts index 3c16647d1d..654fe18366 100644 --- a/packages/nodes/src/shared/wall-attach-target.ts +++ b/packages/nodes/src/shared/wall-attach-target.ts @@ -3,12 +3,10 @@ import { type AnyNodeId, collectLevelWallSegments, getScaledDimensions, - getWallArcData, - getWallCurveFrameAt, getWallCurveLength, type ItemNode, - isCurvedWall, nearestWallSegment, + projectPointToWallCenterline, WALL_SNAP_DISTANCE_M, type WallNode, } from '@pascal-app/core' @@ -112,103 +110,30 @@ export function findClosestWallInPlan( } } -type CurvedWallPlanHit = { - distance: number - localX: number - perpDistance: number - dirX: number - dirY: number - wallLength: number -} - export type WallPlanAttachment = Omit<WallHit, 'wall'> & { distance: number } -function closestCurvedWallInPlan( - wall: WallNode, - planPoint: readonly [number, number], - maxDistance: number, -): CurvedWallPlanHit | null { - const arc = getWallArcData(wall) - const wallLength = getWallCurveLength(wall) - if (!arc || wallLength <= 1e-6) return null - - const pointAngle = Math.atan2(planPoint[1] - arc.center.y, planPoint[0] - arc.center.x) - let directedAngle = (pointAngle - arc.startAngle) * arc.direction - while (directedAngle < 0) directedAngle += Math.PI * 2 - - const candidates = [0, 1] - const arcAngle = Math.abs(arc.delta) - if (directedAngle <= arcAngle) candidates.push(directedAngle / arcAngle) - - let best: { distance: number; t: number } | null = null - for (const t of candidates) { - const frame = getWallCurveFrameAt(wall, t) - const distance = Math.hypot(planPoint[0] - frame.point.x, planPoint[1] - frame.point.y) - if (!best || distance < best.distance) best = { distance, t } - } - if (!best || best.distance > maxDistance) return null - - const frame = getWallCurveFrameAt(wall, best.t) - const perpDistance = - (planPoint[0] - frame.point.x) * frame.normal.x + - (planPoint[1] - frame.point.y) * frame.normal.y - return { - distance: best.distance, - localX: wallLength * best.t, - perpDistance, - dirX: frame.tangent.x, - dirY: frame.tangent.y, - wallLength, - } -} - /** Resolve a plan point against one wall, including its curved centerline. */ export function resolveWallAttachmentAtPlanPoint( wall: WallNode, planPoint: readonly [number, number], maxDistance = WALL_SNAP_DISTANCE_M, ): WallPlanAttachment | null { - if (!isCurvedWall(wall)) { - const dx = wall.end[0] - wall.start[0] - const dz = wall.end[1] - wall.start[1] - const wallLength = Math.hypot(dx, dz) - if (wallLength <= 1e-6) return null - const dirX = dx / wallLength - const dirY = dz / wallLength - const px = planPoint[0] - wall.start[0] - const pz = planPoint[1] - wall.start[1] - const localX = Math.max(0, Math.min(wallLength, px * dirX + pz * dirY)) - const perpDistance = px * -dirY + pz * dirX - const closestX = wall.start[0] + dirX * localX - const closestZ = wall.start[1] + dirY * localX - const distance = Math.hypot(planPoint[0] - closestX, planPoint[1] - closestZ) - if (distance > maxDistance) return null - const side: 'front' | 'back' = perpDistance >= 0 ? 'front' : 'back' - return { - distance, - localX, - perpDistance, - side, - dirX, - dirY, - wallLength, - itemRotation: side === 'front' ? 0 : Math.PI, - } - } - - const curvedHit = closestCurvedWallInPlan(wall, planPoint, maxDistance) - if (!curvedHit || curvedHit.distance > maxDistance) return null - const side: 'front' | 'back' = curvedHit.perpDistance >= 0 ? 'front' : 'back' + const projection = projectPointToWallCenterline(wall, { + x: planPoint[0], + y: planPoint[1], + }) + if (projection.distance > maxDistance) return null + const side: 'front' | 'back' = projection.signedNormalDistance >= 0 ? 'front' : 'back' return { - distance: curvedHit.distance, - localX: curvedHit.localX, - perpDistance: curvedHit.perpDistance, + distance: projection.distance, + localX: projection.distanceAlong, + perpDistance: projection.signedNormalDistance, side, - dirX: curvedHit.dirX, - dirY: curvedHit.dirY, - wallLength: curvedHit.wallLength, + dirX: projection.frame.tangent.x, + dirY: projection.frame.tangent.y, + wallLength: getWallCurveLength(wall), itemRotation: side === 'front' ? 0 : Math.PI, } } diff --git a/packages/nodes/src/slab/placement-ownership.test.ts b/packages/nodes/src/slab/placement-ownership.test.ts index 4fca8f3907..012e6c2117 100644 --- a/packages/nodes/src/slab/placement-ownership.test.ts +++ b/packages/nodes/src/slab/placement-ownership.test.ts @@ -1,19 +1,9 @@ import { describe, expect, test } from 'bun:test' -import { type SlabCompletionTrigger, shouldRegistryCommitSlab } from './placement-ownership' - -function slabCreatorCount(viewMode: '2d' | '3d' | 'split', trigger: SlabCompletionTrigger): number { - const floorplanCommits = viewMode === '2d' && trigger === 'grid' - const registryCommits = shouldRegistryCommitSlab(viewMode, trigger) - return Number(floorplanCommits) + Number(registryCommits) -} +import { shouldRegistryCommitSlab } from './placement-ownership' describe('slab placement ownership', () => { - test.each([ - ['2d', 'grid'], - ['2d', 'keyboard'], - ['3d', 'grid'], - ['split', 'grid'], - ] as const)('commits one slab in %s from %s completion', (viewMode, trigger) => { - expect(slabCreatorCount(viewMode, trigger)).toBe(1) + test.each(['2d', '3d', 'split'] as const)('commits one slab in %s', (viewMode) => { + const floorplanCommits = viewMode === '2d' + expect(Number(floorplanCommits) + Number(shouldRegistryCommitSlab(viewMode))).toBe(1) }) }) diff --git a/packages/nodes/src/slab/placement-ownership.ts b/packages/nodes/src/slab/placement-ownership.ts index 31cdb3d394..8e7cfd3d8f 100644 --- a/packages/nodes/src/slab/placement-ownership.ts +++ b/packages/nodes/src/slab/placement-ownership.ts @@ -1,8 +1,3 @@ -export type SlabCompletionTrigger = 'grid' | 'keyboard' - -export function shouldRegistryCommitSlab( - viewMode: '2d' | '3d' | 'split', - trigger: SlabCompletionTrigger, -): boolean { - return trigger === 'keyboard' || viewMode !== '2d' +export function shouldRegistryCommitSlab(viewMode: '2d' | '3d' | 'split'): boolean { + return viewMode !== '2d' } diff --git a/packages/nodes/src/slab/tool.tsx b/packages/nodes/src/slab/tool.tsx index 80a432618f..312e92a5e9 100644 --- a/packages/nodes/src/slab/tool.tsx +++ b/packages/nodes/src/slab/tool.tsx @@ -21,6 +21,7 @@ import { markToolCancelConsumed, publishHorizontalConstructionPlane, publishPlacementSurface, + registerDrawingControls, resampleTerrainConstructionPlane, resolveEventConstructionPlane, resolveLevelConstructionPlane, @@ -34,7 +35,7 @@ import { useViewer } from '@pascal-app/viewer' import { useThree } from '@react-three/fiber' import { useEffect, useMemo, useRef, useState } from 'react' import { BufferGeometry, DoubleSide, type Group, type Line, Shape, Vector3 } from 'three' -import { type SlabCompletionTrigger, shouldRegistryCommitSlab } from './placement-ownership' +import { shouldRegistryCommitSlab } from './placement-ownership' import { SlabNode } from './schema' /** @@ -205,7 +206,7 @@ export const SlabTool: React.FC = () => { Math.abs(clickPoint[0] - firstPoint[0]) < 0.25 && Math.abs(clickPoint[1] - firstPoint[1]) < 0.25 ) { - if (shouldRegistryCommitSlab(useEditor.getState().viewMode, 'grid')) { + if (shouldRegistryCommitSlab(useEditor.getState().viewMode)) { const slabId = commitSlabDrawing( currentLevelId, points, @@ -235,9 +236,9 @@ export const SlabTool: React.FC = () => { // Finish the polygon (Enter or double-click): commit once there are enough // vertices. Closing near the first vertex (in onGridClick) is the third way. - const finishDrawing = (trigger: SlabCompletionTrigger) => { + const finishDrawing = () => { if (points.length < 3) return - if (shouldRegistryCommitSlab(useEditor.getState().viewMode, trigger)) { + if (shouldRegistryCommitSlab(useEditor.getState().viewMode)) { const slabId = commitSlabDrawing( currentLevelId, points, @@ -249,7 +250,7 @@ export const SlabTool: React.FC = () => { } const onGridDoubleClick = (_event: GridEvent) => { - finishDrawing('grid') + finishDrawing() } const onCancel = () => { @@ -257,13 +258,18 @@ export const SlabTool: React.FC = () => { resetDraft() } - const onKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Enter') { - e.preventDefault() - finishDrawing('keyboard') - } - } - document.addEventListener('keydown', onKeyDown) + const unregisterControls = registerDrawingControls('slab', '3d', { + finish: () => { + if (points.length < 3) return false + finishDrawing() + return true + }, + back: () => { + if (points.length <= 1) resetDraft() + else setPoints(points.slice(0, -1)) + }, + afterFinish: resetDraft, + }) emitter.on('grid:move', onGridMove) emitter.on('grid:click', onGridClick) @@ -271,7 +277,7 @@ export const SlabTool: React.FC = () => { emitter.on('tool:cancel', onCancel) return () => { - document.removeEventListener('keydown', onKeyDown) + unregisterControls() emitter.off('grid:move', onGridMove) emitter.off('grid:click', onGridClick) emitter.off('grid:double-click', onGridDoubleClick) diff --git a/packages/nodes/src/wall/definition.ts b/packages/nodes/src/wall/definition.ts index 88a880db80..74630bcfd7 100644 --- a/packages/nodes/src/wall/definition.ts +++ b/packages/nodes/src/wall/definition.ts @@ -9,6 +9,8 @@ import { DRAFTING_SURFACE_EXTENSION_KEY, type DraftingSurfaceExtension, type FloorplanNodeExtension, + TOOL_OVERLAY_EXTENSION_KEY, + type ToolOverlayExtension, } from '@pascal-app/editor' import { buildWallContextualDimensions } from './contextual-dimensions' import { hasWallCurveBlockingChildren } from './curve-eligibility' @@ -62,6 +64,9 @@ export const wallDefinition: NodeDefinition<typeof WallNode> = { category: 'structure', surfaceRole: 'wall', extensions: { + [TOOL_OVERLAY_EXTENSION_KEY]: { + component: () => import('./tool-overlay'), + } satisfies ToolOverlayExtension, [DRAFTING_SURFACE_EXTENSION_KEY]: { kind: 'wall', classifyFace: (node, localNormal) => { diff --git a/packages/nodes/src/wall/renderer.tsx b/packages/nodes/src/wall/renderer.tsx index 04cecec679..a4ccddb60f 100644 --- a/packages/nodes/src/wall/renderer.tsx +++ b/packages/nodes/src/wall/renderer.tsx @@ -201,7 +201,7 @@ const WallRenderer = ({ node }: { node: WallNode }) => { )} {(node.children ?? []).map((childId) => ( - <NodeRenderer key={`${node.id}:${childId}`} nodeId={childId} /> + <NodeRenderer key={`${node.id}:${childId}`} nodeId={childId as AnyNodeId} /> ))} </mesh> ) diff --git a/packages/nodes/src/wall/tool-overlay.tsx b/packages/nodes/src/wall/tool-overlay.tsx new file mode 100644 index 0000000000..d8c0ecd604 --- /dev/null +++ b/packages/nodes/src/wall/tool-overlay.tsx @@ -0,0 +1,117 @@ +import { + runDrawingControl, + useDraftLength, + useEditor, + useFloorplanDraftPreview, +} from '@pascal-app/editor' +import { useViewer } from '@pascal-app/viewer' +import { useCallback, useEffect, useRef, useState } from 'react' + +function LengthField({ unit, scale }: { unit: string; scale: number }) { + const input = useRef<HTMLInputElement>(null) + const [text, setText] = useState(() => { + const current = useDraftLength.getState().length + return current === null ? '' : String(Number((current / scale).toPrecision(12))) + }) + const value = /^\d*\.?\d+$/.test(text.trim()) ? Number(text) * scale : null + const invalid = text !== '' && (value === null || !Number.isFinite(value) || value < 0.01) + + const update = useCallback( + (next: string) => { + setText(next) + if (next === '') useDraftLength.getState().clear() + else if (/^\d*\.?\d+$/.test(next.trim())) { + useDraftLength.getState().setLength(Number(next) * scale) + } + }, + [scale], + ) + + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if (event.isComposing || event.metaKey || event.ctrlKey || event.altKey) return + const target = event.target + const ownInput = target === input.current + if ( + !ownInput && + target instanceof Element && + target.closest('input, textarea, select, [contenteditable="true"], [role="dialog"]') + ) + return + + if (event.key === 'Escape' && (ownInput || useDraftLength.getState().length !== null)) { + event.preventDefault() + event.stopImmediatePropagation() + setText('') + useDraftLength.getState().clear() + input.current?.blur() + } else if (ownInput && event.key === 'Enter') { + event.preventDefault() + event.stopImmediatePropagation() + if ( + !invalid && + !event.repeat && + runDrawingControl('wall', 'finish', useEditor.getState().viewMode) + ) { + input.current?.blur() + } + } else if (!ownInput && /^[0-9.]$/.test(event.key)) { + event.preventDefault() + event.stopImmediatePropagation() + update(event.key) + input.current?.focus({ preventScroll: true }) + } + } + window.addEventListener('keydown', onKeyDown, true) + return () => window.removeEventListener('keydown', onKeyDown, true) + }, [invalid, update]) + + return ( + <div + className="pointer-events-auto fixed bottom-24 left-1/2 z-50 flex -translate-x-1/2 items-center gap-2 rounded-lg border border-border bg-background/95 px-3 py-1.5 text-foreground shadow-sm focus-within:ring-1 focus-within:ring-ring" + title="Type a length · Enter or click to place · Esc to clear" + > + <input + aria-describedby={invalid ? 'wall-draft-length-hint' : undefined} + aria-invalid={invalid} + aria-label={`Wall length (${unit})`} + className="w-20 bg-transparent text-sm tabular-nums outline-none placeholder:text-muted-foreground" + id="wall-draft-length" + inputMode="decimal" + onBlur={() => { + const current = useDraftLength.getState().length + setText(current === null ? '' : String(Number((current / scale).toPrecision(12)))) + }} + onChange={(event) => update(event.target.value)} + onFocus={(event) => event.target.select()} + onKeyDown={(event) => event.stopPropagation()} + placeholder="Length" + ref={input} + value={text} + /> + <span aria-hidden="true" className="text-xs text-muted-foreground"> + {unit} + </span> + {invalid && ( + <p + className="absolute bottom-full left-1/2 mb-2 -translate-x-1/2 whitespace-nowrap rounded-md bg-background px-2 py-1 text-xs text-destructive shadow-sm" + id="wall-draft-length-hint" + role="status" + > + Enter a length of at least 1 cm. + </p> + )} + </div> + ) +} + +export default function WallToolOverlay() { + const start = useFloorplanDraftPreview((state) => state.wallDraftStart) + const unit = useViewer((state) => state.unit) + const metricNotation = useViewer((state) => state.metricNotation) + const label = unit === 'imperial' ? 'ft' : metricNotation === 'millimeters' ? 'mm' : 'm' + const scale = label === 'ft' ? 0.3048 : label === 'mm' ? 0.001 : 1 + useEffect(() => () => useDraftLength.getState().clear(), []) + if (!start) return null + return <LengthField key={`${start[0]},${start[1]},${label}`} scale={scale} unit={label} /> +} diff --git a/packages/nodes/src/wall/tool.tsx b/packages/nodes/src/wall/tool.tsx index 905611ead4..ea27cfc079 100644 --- a/packages/nodes/src/wall/tool.tsx +++ b/packages/nodes/src/wall/tool.tsx @@ -22,6 +22,7 @@ import { CursorSphere, chainEndJoinsExistingWall, clearPlacementSurface, + constrainDraftPointToLength, createWallOnCurrentLevel, DraftMeasurementLabel, EDITOR_LAYER, @@ -37,6 +38,7 @@ import { markToolCancelConsumed, publishHorizontalConstructionPlane, publishPlacementSurface, + registerDrawingControls, resampleTerrainConstructionPlane, resolveEventConstructionPlane, resolvePointerSupportSurface, @@ -44,6 +46,7 @@ import { snapWallDraftPointDetailed, triggerSFX, useAlignmentGuides, + useDraftLength, useEditor, useFloorplanDraftPreview, useSegmentDraftChain, @@ -494,6 +497,7 @@ const LineWallTool: React.FC = () => { useEffect(() => { let gridPosition: WallPlanPoint = [0, 0] let previousWallEnd: [number, number] | null = null + let lastGridEvent: GridEvent | null = null // Alignment candidates — anchors of every alignable object. Refreshed // after each segment commits (the new wall becomes a candidate too). @@ -611,6 +615,8 @@ const LineWallTool: React.FC = () => { const draftPreview = useFloorplanDraftPreview.getState() draftPreview.setWallDraftStart(null) draftPreview.setWallDraftEnd(null) + draftPreview.setWallDraftPointerEnd(null) + useDraftLength.getState().clear() if (wallPreviewRef.current) { wallPreviewRef.current.visible = false } @@ -623,7 +629,9 @@ const LineWallTool: React.FC = () => { } const onGridMove = (event: GridEvent) => { + if (useEditor.getState().viewMode === '2d') return if (!(cursorRef.current && wallPreviewRef.current)) return + lastGridEvent = event // Ride the grid event plane on the pointed surface: aiming at an // elevated deck lifts the plane to the deck top, so the draft's XZ @@ -659,12 +667,26 @@ const LineWallTool: React.FC = () => { magnetic: isMagneticSnapActive(), }) gridPosition = alignPoint(snapResult.point, { applySnap: !angleLocked }) + if (buildingState.current === 1) { + const draft = useFloorplanDraftPreview.getState() + draft.setWallDraftStart([startingPoint.current.x, startingPoint.current.z]) + draft.setWallDraftPointerEnd(gridPosition) + gridPosition = constrainDraftPointToLength( + [startingPoint.current.x, startingPoint.current.z], + gridPosition, + useDraftLength.getState().length, + ) + draft.setWallDraftEnd(gridPosition) + if (useDraftLength.getState().length !== null) { + useAlignmentGuides.getState().clear() + } + } // Stand the magnetic beacon at the endpoint when it locked onto an // existing wall corner / wall point; clear it for plain grid/angle moves. useWallSnapIndicator .getState() .set( - snapResult.snap + snapResult.snap && useDraftLength.getState().length === null ? { x: gridPosition[0], z: gridPosition[1], kind: snapResult.snap } : null, ) @@ -673,9 +695,6 @@ const LineWallTool: React.FC = () => { const snappedLocal = gridPosition const draftY = constructionPlane.current?.localY ?? event.localPosition[1] endingPoint.current.set(snappedLocal[0], draftY, snappedLocal[1]) - const draftPreview = useFloorplanDraftPreview.getState() - draftPreview.setWallDraftStart([startingPoint.current.x, startingPoint.current.z]) - draftPreview.setWallDraftEnd(snappedLocal) cursorRef.current.position.copy(endingPoint.current) setAxisGuide({ origin: [startingPoint.current.x, startingPoint.current.z], @@ -726,10 +745,12 @@ const LineWallTool: React.FC = () => { } } - const onGridClick = (event: GridEvent) => { + const onGridClick = (event: GridEvent, previewEnd?: WallPlanPoint) => { + // The floorplan owns commits in 2D; the hidden canvas can still receive its grid events. + if (useEditor.getState().viewMode === '2d') return if (!wallPreviewRef.current) return - if (buildingState.current === 1 && event.nativeEvent.detail >= 2) { + if (!previewEnd && buildingState.current === 1 && event.nativeEvent.detail >= 2) { stopDrafting() return } @@ -765,6 +786,7 @@ const LineWallTool: React.FC = () => { buildingState.current = 1 const draftPreview = useFloorplanDraftPreview.getState() draftPreview.setWallDraftStart(snappedStart) + draftPreview.setWallDraftPointerEnd(snappedStart) draftPreview.setWallDraftEnd(snappedStart) setAxisGuide({ origin: snappedStart, @@ -779,15 +801,22 @@ const LineWallTool: React.FC = () => { setDraftMeasurement(null) } else if (buildingState.current === 1) { const angleLocked = isAngleSnapActive() - const snappedEnd = alignPoint( - snapWallDraftPointDetailed({ - point: localClick, - walls: snapWalls, - start: angleLocked ? [startingPoint.current.x, startingPoint.current.z] : undefined, - angleSnap: angleLocked, - magnetic: isMagneticSnapActive(), - }).point, - { applySnap: !angleLocked }, + let snappedEnd = + previewEnd ?? + alignPoint( + snapWallDraftPointDetailed({ + point: localClick, + walls: snapWalls, + start: angleLocked ? [startingPoint.current.x, startingPoint.current.z] : undefined, + angleSnap: angleLocked, + magnetic: isMagneticSnapActive(), + }).point, + { applySnap: !angleLocked }, + ) + snappedEnd = constrainDraftPointToLength( + [startingPoint.current.x, startingPoint.current.z], + snappedEnd, + useDraftLength.getState().length, ) const dx = snappedEnd[0] - startingPoint.current.x const dz = snappedEnd[1] - startingPoint.current.z @@ -849,6 +878,7 @@ const LineWallTool: React.FC = () => { } const nextStart = createdWall.end + useDraftLength.getState().clear() // Publish the resolved chain start so the 2D floor-plan draft // chains its next segment from the same point (its own snap // pipeline can resolve a slightly different endpoint). @@ -859,6 +889,7 @@ const LineWallTool: React.FC = () => { const draftPreview = useFloorplanDraftPreview.getState() draftPreview.setWallDraftEnd(null) draftPreview.setWallDraftStart(nextStart) + draftPreview.setWallDraftPointerEnd(nextStart) draftPreview.setWallDraftEnd(nextStart) cursorRef.current?.position.copy(startingPoint.current) buildingState.current = 1 @@ -885,11 +916,29 @@ const LineWallTool: React.FC = () => { } } + const unregisterControls = registerDrawingControls('wall', '3d', { + finish: () => { + const end = useFloorplanDraftPreview.getState().wallDraftEnd + if (buildingState.current !== 1 || !lastGridEvent || !end) return false + const before = useScene.getState().nodes + onGridClick(lastGridEvent, end) + return useScene.getState().nodes !== before + }, + back: stopDrafting, + }) + emitter.on('grid:move', onGridMove) emitter.on('grid:click', onGridClick) emitter.on('tool:cancel', onCancel) + const unsubscribeLength = useDraftLength.subscribe((state, previous) => { + if (state.length !== previous.length && buildingState.current === 1 && lastGridEvent) { + onGridMove(lastGridEvent) + } + }) return () => { + unregisterControls() + unsubscribeLength() emitter.off('grid:move', onGridMove) emitter.off('grid:click', onGridClick) emitter.off('tool:cancel', onCancel) @@ -900,6 +949,8 @@ const LineWallTool: React.FC = () => { const draftPreview = useFloorplanDraftPreview.getState() draftPreview.setWallDraftStart(null) draftPreview.setWallDraftEnd(null) + draftPreview.setWallDraftPointerEnd(null) + useDraftLength.getState().clear() } }, [unit, metricNotation]) diff --git a/packages/nodes/src/wall/treatments.tsx b/packages/nodes/src/wall/treatments.tsx index 9296a98164..996b0ff871 100644 --- a/packages/nodes/src/wall/treatments.tsx +++ b/packages/nodes/src/wall/treatments.tsx @@ -2,6 +2,7 @@ import { getWallCurveFrameAt, + getWallCurveSampleCount, getWallMiterBoundaryPoints, getWallThickness, isCurvedWall, @@ -29,7 +30,6 @@ import { mergeGeometries as mergeBufferGeometries } from 'three/examples/jsm/uti import { resolveWallOpeningCeiling } from '../shared/wall-opening-ceiling' import { treatmentMiterDataForProud, type WallTreatmentLevelData } from './treatment-level-data' -const CURVE_SEGMENTS = 24 const MIN_SLICE_PROUD = 0.0005 const EPS = 1e-6 @@ -321,7 +321,7 @@ function wallToLocalTransform(node: WallNode) { function buildSidePolyline(node: WallNode, side: WallSide, offset: number): Point2[] { const sideSign = resolveTreatmentSideSign(node, side) const toLocal = wallToLocalTransform(node) - const sampleCount = isCurvedWall(node) ? CURVE_SEGMENTS : 1 + const sampleCount = isCurvedWall(node) ? getWallCurveSampleCount(node) : 1 const points: Point2[] = [] for (let index = 0; index <= sampleCount; index += 1) { diff --git a/packages/viewer/src/systems/wall/wall-system.tsx b/packages/viewer/src/systems/wall/wall-system.tsx index ce152367fb..38bbe11a53 100644 --- a/packages/viewer/src/systems/wall/wall-system.tsx +++ b/packages/viewer/src/systems/wall/wall-system.tsx @@ -7,6 +7,7 @@ import { getEffectiveNode, getWallBandSlotId, getWallCurveFrameAt, + getWallCurveSampleCount, getWallFaceBandConfig, getWallFaceBandForHeight, getWallMiterBoundaryPoints, @@ -633,7 +634,7 @@ function wallRebuildExitReason( if (wall?.type !== 'wall') return null let cutouts = 0 for (const childId of getEffectiveWall(wall).children ?? []) { - const child = nodes[childId] + const child = nodes[childId as AnyNodeId] if ( child?.type === 'door' || child?.type === 'window' || @@ -996,7 +997,7 @@ function updateWallGeometry(wallId: string, miterData: WallMiterData) { // without waiting on the scene store. Non-cutout children pass through // unchanged. const childrenNodes = childrenIds - .map((childId) => nodes[childId]) + .map((childId) => nodes[childId as AnyNodeId]) .filter((n): n is AnyNode => n !== undefined) .map((child) => { if (child.type !== 'door' && child.type !== 'window') return child @@ -1232,7 +1233,7 @@ export function generateExtrudedWall( const polyPoints = isCurvedWall(wallNode) ? getWallSurfacePolygon( wallNode, - 24, + undefined, insetCurvedWallBoundaryPointsFor3D(wallNode, boundaryPoints, miterData) ?? undefined, ) : getWallPlanFootprint(wallNode, miterData) @@ -1300,7 +1301,10 @@ export function generateExtrudedWall( const worldCutoutPoints: Point2D[] = [] if (isCurvedWall(wallNode)) { - const sampleCount = Math.max(2, Math.ceil((segmentEnd - segmentStart) * 24)) + const sampleCount = Math.max( + 2, + Math.ceil((segmentEnd - segmentStart) * getWallCurveSampleCount(wallNode)), + ) const left: Point2D[] = [] const right: Point2D[] = [] for (let index = 0; index <= sampleCount; index++) {