diff --git a/.agents/skills/open-pr/SKILL.md b/.agents/skills/open-pr/SKILL.md index ceab50457..b121ea0fb 100644 --- a/.agents/skills/open-pr/SKILL.md +++ b/.agents/skills/open-pr/SKILL.md @@ -1,10 +1,10 @@ --- name: open-pr -description: Open a pull request on pascalorg/editor using the repo's PR template. Use when the user asks to open/create a PR, push and PR, or ship a branch in the editor repo. +description: Open or update a pull request on pascalorg/editor using the repo's PR template. Use when the user asks to open/create a PR, push and PR, ship a branch, or refresh a PR description after new commits in the editor repo. allowed-tools: Bash(git *) Bash(gh *) Read --- -Open a pull request against `pascalorg/editor` from the current branch. +Open a pull request against `pascalorg/editor` from the current branch, or — if a PR for the branch already exists — push new work and reconcile the PR description against the current `main..HEAD` delta. ## 1. Pre-flight @@ -16,8 +16,13 @@ git log --oneline main..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`. Nothing to open a PR for. -- There are uncommitted changes the user hasn't asked to commit. +- The branch has no commits ahead of `main` **and** no uncommitted changes. Nothing to ship. + +If there are **uncommitted changes**, do not silently skip them: + +1. Show the user `git status` and `git diff --stat`. +2. Ask for a commit message (do not auto-generate — this is an explicit "ship it" moment). +3. Stage the intended files and create the commit with the user-provided message. Run a build sanity check if the change is non-trivial: @@ -26,36 +31,50 @@ bun typecheck bun build ``` -Don't open the PR with a broken build. +Don't open or update the PR with a broken build. ## 2. Read the PR template -The template is at `.github/pull_request_template.md`. Read it before composing the body — the section headings and checklist items are the source of truth, not your memory of them. +The template is at `.github/pull_request_template.md`. Read it before composing or reconciling the body — the section headings and checklist items are the source of truth, not your memory of them. ```bash cat .github/pull_request_template.md ``` -Mirror the template exactly: +Template sections (mirror exactly): -- `## What does this PR do?` — one paragraph or a short bullet list. Link related issues with `Fixes #123` when applicable. -- `## How to test` — numbered, concrete reviewer steps (commands to run, what to click, expected outcome). -- `## Screenshots / screen recording` — if the change is visual, paste a recording link or note that one will be added. If purely non-visual (refactor, internal API), say so explicitly so the reviewer knows nothing is missing. -- `## Checklist` — copy the boxes verbatim, ticking the ones already verified. +- `## What does this PR do?` — one paragraph or short bullet list. Link related issues with `Fixes #123`. +- `## How to test` — numbered, concrete reviewer steps. +- `## Screenshots / screen recording` — link, or `N/A — non-visual change` if it doesn't apply. +- `## Checklist` — the boxes from the template, verbatim. -## 3. Push and open +## 3. Push ```bash git push -u origin HEAD ``` -Check for an existing PR first: +This updates an existing PR's commits/files automatically if one is already open. The description, however, does **not** auto-update — that's what step 5 handles. + +## 4. Detect existing PR + +```bash +gh pr view --json url,number,title,body 2>/dev/null +``` + +- If the command returns nothing → no PR exists → go to **step 5a (create)**. +- If it returns a PR → capture `url`, `number`, `title`, `body` → go to **step 5b (reconcile)**. + +## 5a. Create (no existing PR) + +Compose the body from the current `main..HEAD` delta: ```bash -gh pr view --json url 2>/dev/null +git log --oneline main..HEAD +git diff --stat main...HEAD ``` -If none exists, create it. Pass the body via HEREDOC to preserve markdown formatting: +Fill the template sections based on that delta. Then: ```bash gh pr create --title "short, scope-prefixed title" --body "$(cat <<'EOF' @@ -85,13 +104,45 @@ EOF Keep the title under ~70 characters. Use a scope prefix when there's an obvious one (`viewer:`, `core:`, `editor:`, `mcp:`). -If a PR already exists, print its URL and stop — don't recreate. +## 5b. Reconcile (existing PR) + +**Goal:** keep what's still accurate in the existing description (including any manual edits the user made in the GitHub UI), update what's now wrong, and add what's missing. Do **not** blindly overwrite. + +Steps: + +1. Re-read the existing body captured in step 4. +2. Compute the current delta: + ```bash + git log --oneline main..HEAD + git diff --stat main...HEAD + git diff main...HEAD + ``` +3. Section by section, produce a reconciled body: + - **What does this PR do?** — Keep existing sentences/bullets that still describe the branch. Rewrite or remove ones that no longer match the diff. Add bullets for new commits/features not yet mentioned. + - **How to test** — Keep existing steps that still work. Update commands/paths that have changed. Add steps for new behavior. Remove steps for behavior that was reverted or removed. + - **Screenshots / screen recording** — Preserve existing links verbatim. If the change is now visual and no link exists, note `` rather than removing the section. + - **Checklist** — **Preserve the user's checkbox states exactly** (ticked or unticked). Do not re-tick based on this run's verification. If a box is unchecked but you verified its condition (e.g. `bun check` passed), surface a *note in the final report* — do not modify the box. +4. Preserve the template's section order and headings. +5. Write the reconciled body back: + + ```bash + gh pr edit --body "$(cat <<'EOF' + + EOF + )" + ``` + + Update `--title` too **only if** the scope clearly changed (e.g. branch started as `editor:` work but now also touches `core:`). Otherwise leave the title alone. + +If a reconcile would produce a body identical to the existing one, skip `gh pr edit` and note "description already up to date" in the report. -## 4. Report +## 6. Report Return: - PR URL -- Title used +- Whether the PR was **created** or **updated** (and if updated, whether the description was changed or already up to date) +- Title used (and whether it was changed) +- Commits pushed this run (from `git log`) - Local typecheck/build status (if you ran them) -- A note for the reviewer if anything in the checklist is left unchecked +- Notes for the reviewer about any unchecked checklist items whose conditions you verified this run diff --git a/apps/editor/public/icons/elevator.png b/apps/editor/public/icons/elevator.png new file mode 100644 index 000000000..d0278d622 Binary files /dev/null and b/apps/editor/public/icons/elevator.png differ diff --git a/apps/editor/public/icons/elevator.svg b/apps/editor/public/icons/elevator.svg deleted file mode 100644 index ddc9563e1..000000000 --- a/apps/editor/public/icons/elevator.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - diff --git a/apps/editor/public/material/flooring/brick_wall_aged/brick_wall_aged_ambientocclusion.jpg b/apps/editor/public/material/flooring/brick_wall_aged/brick_wall_aged_ambientocclusion.jpg new file mode 100644 index 000000000..5ad4e8d73 Binary files /dev/null and b/apps/editor/public/material/flooring/brick_wall_aged/brick_wall_aged_ambientocclusion.jpg differ diff --git a/apps/editor/public/material/flooring/brick_wall_aged/brick_wall_aged_basecolor.jpg b/apps/editor/public/material/flooring/brick_wall_aged/brick_wall_aged_basecolor.jpg new file mode 100644 index 000000000..fa52d1999 Binary files /dev/null and b/apps/editor/public/material/flooring/brick_wall_aged/brick_wall_aged_basecolor.jpg differ diff --git a/apps/editor/public/material/flooring/brick_wall_aged/brick_wall_aged_height.jpg b/apps/editor/public/material/flooring/brick_wall_aged/brick_wall_aged_height.jpg new file mode 100644 index 000000000..31361d4ec Binary files /dev/null and b/apps/editor/public/material/flooring/brick_wall_aged/brick_wall_aged_height.jpg differ diff --git a/apps/editor/public/material/flooring/brick_wall_aged/brick_wall_aged_metallic.jpg b/apps/editor/public/material/flooring/brick_wall_aged/brick_wall_aged_metallic.jpg new file mode 100644 index 000000000..c18580801 Binary files /dev/null and b/apps/editor/public/material/flooring/brick_wall_aged/brick_wall_aged_metallic.jpg differ diff --git a/apps/editor/public/material/flooring/brick_wall_aged/brick_wall_aged_normal.jpg b/apps/editor/public/material/flooring/brick_wall_aged/brick_wall_aged_normal.jpg new file mode 100644 index 000000000..21cea7067 Binary files /dev/null and b/apps/editor/public/material/flooring/brick_wall_aged/brick_wall_aged_normal.jpg differ diff --git a/apps/editor/public/material/flooring/brick_wall_aged/brick_wall_aged_roughness.jpg b/apps/editor/public/material/flooring/brick_wall_aged/brick_wall_aged_roughness.jpg new file mode 100644 index 000000000..0bee2841b Binary files /dev/null and b/apps/editor/public/material/flooring/brick_wall_aged/brick_wall_aged_roughness.jpg differ diff --git a/apps/editor/public/material/flooring/brick_wall_rustic/brick_wall_rustic_ambientocclusion.jpg b/apps/editor/public/material/flooring/brick_wall_rustic/brick_wall_rustic_ambientocclusion.jpg new file mode 100644 index 000000000..fe2c2ee2a Binary files /dev/null and b/apps/editor/public/material/flooring/brick_wall_rustic/brick_wall_rustic_ambientocclusion.jpg differ diff --git a/apps/editor/public/material/flooring/brick_wall_rustic/brick_wall_rustic_basecolor.jpg b/apps/editor/public/material/flooring/brick_wall_rustic/brick_wall_rustic_basecolor.jpg new file mode 100644 index 000000000..8c351eac3 Binary files /dev/null and b/apps/editor/public/material/flooring/brick_wall_rustic/brick_wall_rustic_basecolor.jpg differ diff --git a/apps/editor/public/material/flooring/brick_wall_rustic/brick_wall_rustic_height.jpg b/apps/editor/public/material/flooring/brick_wall_rustic/brick_wall_rustic_height.jpg new file mode 100644 index 000000000..af3013c84 Binary files /dev/null and b/apps/editor/public/material/flooring/brick_wall_rustic/brick_wall_rustic_height.jpg differ diff --git a/apps/editor/public/material/flooring/brick_wall_rustic/brick_wall_rustic_metallic.jpg b/apps/editor/public/material/flooring/brick_wall_rustic/brick_wall_rustic_metallic.jpg new file mode 100644 index 000000000..c18580801 Binary files /dev/null and b/apps/editor/public/material/flooring/brick_wall_rustic/brick_wall_rustic_metallic.jpg differ diff --git a/apps/editor/public/material/flooring/brick_wall_rustic/brick_wall_rustic_normal.jpg b/apps/editor/public/material/flooring/brick_wall_rustic/brick_wall_rustic_normal.jpg new file mode 100644 index 000000000..02d3e7adf Binary files /dev/null and b/apps/editor/public/material/flooring/brick_wall_rustic/brick_wall_rustic_normal.jpg differ diff --git a/apps/editor/public/material/flooring/brick_wall_rustic/brick_wall_rustic_roughness.jpg b/apps/editor/public/material/flooring/brick_wall_rustic/brick_wall_rustic_roughness.jpg new file mode 100644 index 000000000..48ebe11e6 Binary files /dev/null and b/apps/editor/public/material/flooring/brick_wall_rustic/brick_wall_rustic_roughness.jpg differ diff --git a/apps/editor/public/material/flooring/brick_wall_weathered/brick_wall_weathered_ambientocclusion.jpg b/apps/editor/public/material/flooring/brick_wall_weathered/brick_wall_weathered_ambientocclusion.jpg new file mode 100644 index 000000000..9975608d7 Binary files /dev/null and b/apps/editor/public/material/flooring/brick_wall_weathered/brick_wall_weathered_ambientocclusion.jpg differ diff --git a/apps/editor/public/material/flooring/brick_wall_weathered/brick_wall_weathered_basecolor.jpg b/apps/editor/public/material/flooring/brick_wall_weathered/brick_wall_weathered_basecolor.jpg new file mode 100644 index 000000000..6b5fb554a Binary files /dev/null and b/apps/editor/public/material/flooring/brick_wall_weathered/brick_wall_weathered_basecolor.jpg differ diff --git a/apps/editor/public/material/flooring/brick_wall_weathered/brick_wall_weathered_height.jpg b/apps/editor/public/material/flooring/brick_wall_weathered/brick_wall_weathered_height.jpg new file mode 100644 index 000000000..513b22a40 Binary files /dev/null and b/apps/editor/public/material/flooring/brick_wall_weathered/brick_wall_weathered_height.jpg differ diff --git a/apps/editor/public/material/flooring/brick_wall_weathered/brick_wall_weathered_normal.jpg b/apps/editor/public/material/flooring/brick_wall_weathered/brick_wall_weathered_normal.jpg new file mode 100644 index 000000000..be5a09b70 Binary files /dev/null and b/apps/editor/public/material/flooring/brick_wall_weathered/brick_wall_weathered_normal.jpg differ diff --git a/apps/editor/public/material/flooring/brick_wall_weathered/brick_wall_weathered_roughness.jpg b/apps/editor/public/material/flooring/brick_wall_weathered/brick_wall_weathered_roughness.jpg new file mode 100644 index 000000000..00126499c Binary files /dev/null and b/apps/editor/public/material/flooring/brick_wall_weathered/brick_wall_weathered_roughness.jpg differ diff --git a/apps/editor/public/material/flooring/ceramic_mosaic/ceramic_mosaic_ambientocclusion.jpg b/apps/editor/public/material/flooring/ceramic_mosaic/ceramic_mosaic_ambientocclusion.jpg new file mode 100644 index 000000000..18f7615b8 Binary files /dev/null and b/apps/editor/public/material/flooring/ceramic_mosaic/ceramic_mosaic_ambientocclusion.jpg differ diff --git a/apps/editor/public/material/flooring/ceramic_mosaic/ceramic_mosaic_basecolor.jpg b/apps/editor/public/material/flooring/ceramic_mosaic/ceramic_mosaic_basecolor.jpg new file mode 100644 index 000000000..c97be93c1 Binary files /dev/null and b/apps/editor/public/material/flooring/ceramic_mosaic/ceramic_mosaic_basecolor.jpg differ diff --git a/apps/editor/public/material/flooring/ceramic_mosaic/ceramic_mosaic_basepattern.jpg b/apps/editor/public/material/flooring/ceramic_mosaic/ceramic_mosaic_basepattern.jpg new file mode 100644 index 000000000..0627ce841 Binary files /dev/null and b/apps/editor/public/material/flooring/ceramic_mosaic/ceramic_mosaic_basepattern.jpg differ diff --git a/apps/editor/public/material/flooring/ceramic_mosaic/ceramic_mosaic_height.jpg b/apps/editor/public/material/flooring/ceramic_mosaic/ceramic_mosaic_height.jpg new file mode 100644 index 000000000..c6e63434d Binary files /dev/null and b/apps/editor/public/material/flooring/ceramic_mosaic/ceramic_mosaic_height.jpg differ diff --git a/apps/editor/public/material/flooring/ceramic_mosaic/ceramic_mosaic_metallic.jpg b/apps/editor/public/material/flooring/ceramic_mosaic/ceramic_mosaic_metallic.jpg new file mode 100644 index 000000000..c18580801 Binary files /dev/null and b/apps/editor/public/material/flooring/ceramic_mosaic/ceramic_mosaic_metallic.jpg differ diff --git a/apps/editor/public/material/flooring/ceramic_mosaic/ceramic_mosaic_normal.png b/apps/editor/public/material/flooring/ceramic_mosaic/ceramic_mosaic_normal.png new file mode 100644 index 000000000..2d90cdd4d Binary files /dev/null and b/apps/editor/public/material/flooring/ceramic_mosaic/ceramic_mosaic_normal.png differ diff --git a/apps/editor/public/material/flooring/ceramic_mosaic/ceramic_mosaic_roughness.jpg b/apps/editor/public/material/flooring/ceramic_mosaic/ceramic_mosaic_roughness.jpg new file mode 100644 index 000000000..9d85d817c Binary files /dev/null and b/apps/editor/public/material/flooring/ceramic_mosaic/ceramic_mosaic_roughness.jpg differ diff --git a/apps/editor/public/material/flooring/dark_ceramic_grunge/dark_ceramic_grunge_ambientocclusion.jpg b/apps/editor/public/material/flooring/dark_ceramic_grunge/dark_ceramic_grunge_ambientocclusion.jpg new file mode 100644 index 000000000..9eccf937a Binary files /dev/null and b/apps/editor/public/material/flooring/dark_ceramic_grunge/dark_ceramic_grunge_ambientocclusion.jpg differ diff --git a/apps/editor/public/material/flooring/dark_ceramic_grunge/dark_ceramic_grunge_basecolor.jpg b/apps/editor/public/material/flooring/dark_ceramic_grunge/dark_ceramic_grunge_basecolor.jpg new file mode 100644 index 000000000..b612f82a2 Binary files /dev/null and b/apps/editor/public/material/flooring/dark_ceramic_grunge/dark_ceramic_grunge_basecolor.jpg differ diff --git a/apps/editor/public/material/flooring/dark_ceramic_grunge/dark_ceramic_grunge_height.jpg b/apps/editor/public/material/flooring/dark_ceramic_grunge/dark_ceramic_grunge_height.jpg new file mode 100644 index 000000000..5b8406efb Binary files /dev/null and b/apps/editor/public/material/flooring/dark_ceramic_grunge/dark_ceramic_grunge_height.jpg differ diff --git a/apps/editor/public/material/flooring/dark_ceramic_grunge/dark_ceramic_grunge_metallic.jpg b/apps/editor/public/material/flooring/dark_ceramic_grunge/dark_ceramic_grunge_metallic.jpg new file mode 100644 index 000000000..c18580801 Binary files /dev/null and b/apps/editor/public/material/flooring/dark_ceramic_grunge/dark_ceramic_grunge_metallic.jpg differ diff --git a/apps/editor/public/material/flooring/dark_ceramic_grunge/dark_ceramic_grunge_normal.jpg b/apps/editor/public/material/flooring/dark_ceramic_grunge/dark_ceramic_grunge_normal.jpg new file mode 100644 index 000000000..9a0cd9159 Binary files /dev/null and b/apps/editor/public/material/flooring/dark_ceramic_grunge/dark_ceramic_grunge_normal.jpg differ diff --git a/apps/editor/public/material/flooring/dark_ceramic_grunge/dark_ceramic_grunge_roughness.jpg b/apps/editor/public/material/flooring/dark_ceramic_grunge/dark_ceramic_grunge_roughness.jpg new file mode 100644 index 000000000..b96bf20c5 Binary files /dev/null and b/apps/editor/public/material/flooring/dark_ceramic_grunge/dark_ceramic_grunge_roughness.jpg differ diff --git a/apps/editor/public/material/flooring/garage_panel/garage_panel_ao.jpg b/apps/editor/public/material/flooring/garage_panel/garage_panel_ao.jpg new file mode 100644 index 000000000..c204289b9 Binary files /dev/null and b/apps/editor/public/material/flooring/garage_panel/garage_panel_ao.jpg differ diff --git a/apps/editor/public/material/flooring/garage_panel/garage_panel_diffuse.jpg b/apps/editor/public/material/flooring/garage_panel/garage_panel_diffuse.jpg new file mode 100644 index 000000000..edc9f0a65 Binary files /dev/null and b/apps/editor/public/material/flooring/garage_panel/garage_panel_diffuse.jpg differ diff --git a/apps/editor/public/material/flooring/garage_panel/garage_panel_displacement.jpg b/apps/editor/public/material/flooring/garage_panel/garage_panel_displacement.jpg new file mode 100644 index 000000000..fc9d3e7a7 Binary files /dev/null and b/apps/editor/public/material/flooring/garage_panel/garage_panel_displacement.jpg differ diff --git a/apps/editor/public/material/flooring/garage_panel/garage_panel_normal.jpg b/apps/editor/public/material/flooring/garage_panel/garage_panel_normal.jpg new file mode 100644 index 000000000..0841de283 Binary files /dev/null and b/apps/editor/public/material/flooring/garage_panel/garage_panel_normal.jpg differ diff --git a/apps/editor/public/material/flooring/garage_panel/garage_panel_specular.jpg b/apps/editor/public/material/flooring/garage_panel/garage_panel_specular.jpg new file mode 100644 index 000000000..be8549d89 Binary files /dev/null and b/apps/editor/public/material/flooring/garage_panel/garage_panel_specular.jpg differ diff --git a/apps/editor/public/material/flooring/green_glass_quartzite/green_glass_quartzite_ao.jpg b/apps/editor/public/material/flooring/green_glass_quartzite/green_glass_quartzite_ao.jpg new file mode 100644 index 000000000..9d0bad305 Binary files /dev/null and b/apps/editor/public/material/flooring/green_glass_quartzite/green_glass_quartzite_ao.jpg differ diff --git a/apps/editor/public/material/flooring/green_glass_quartzite/green_glass_quartzite_diffuse.jpg b/apps/editor/public/material/flooring/green_glass_quartzite/green_glass_quartzite_diffuse.jpg new file mode 100644 index 000000000..dc829394a Binary files /dev/null and b/apps/editor/public/material/flooring/green_glass_quartzite/green_glass_quartzite_diffuse.jpg differ diff --git a/apps/editor/public/material/flooring/green_glass_quartzite/green_glass_quartzite_displacement.jpg b/apps/editor/public/material/flooring/green_glass_quartzite/green_glass_quartzite_displacement.jpg new file mode 100644 index 000000000..b4749037e Binary files /dev/null and b/apps/editor/public/material/flooring/green_glass_quartzite/green_glass_quartzite_displacement.jpg differ diff --git a/apps/editor/public/material/flooring/green_glass_quartzite/green_glass_quartzite_normal.jpg b/apps/editor/public/material/flooring/green_glass_quartzite/green_glass_quartzite_normal.jpg new file mode 100644 index 000000000..6142105c0 Binary files /dev/null and b/apps/editor/public/material/flooring/green_glass_quartzite/green_glass_quartzite_normal.jpg differ diff --git a/apps/editor/public/material/flooring/green_glass_quartzite/green_glass_quartzite_specular.jpg b/apps/editor/public/material/flooring/green_glass_quartzite/green_glass_quartzite_specular.jpg new file mode 100644 index 000000000..518ed9d0b Binary files /dev/null and b/apps/editor/public/material/flooring/green_glass_quartzite/green_glass_quartzite_specular.jpg differ diff --git a/apps/editor/public/material/flooring/green_labradorite/green_labradorite_ao.jpg b/apps/editor/public/material/flooring/green_labradorite/green_labradorite_ao.jpg new file mode 100644 index 000000000..e0848226b Binary files /dev/null and b/apps/editor/public/material/flooring/green_labradorite/green_labradorite_ao.jpg differ diff --git a/apps/editor/public/material/flooring/green_labradorite/green_labradorite_diffuse.jpg b/apps/editor/public/material/flooring/green_labradorite/green_labradorite_diffuse.jpg new file mode 100644 index 000000000..5a949051c Binary files /dev/null and b/apps/editor/public/material/flooring/green_labradorite/green_labradorite_diffuse.jpg differ diff --git a/apps/editor/public/material/flooring/green_labradorite/green_labradorite_displacement.jpg b/apps/editor/public/material/flooring/green_labradorite/green_labradorite_displacement.jpg new file mode 100644 index 000000000..e42a3f55e Binary files /dev/null and b/apps/editor/public/material/flooring/green_labradorite/green_labradorite_displacement.jpg differ diff --git a/apps/editor/public/material/flooring/green_labradorite/green_labradorite_normal.jpg b/apps/editor/public/material/flooring/green_labradorite/green_labradorite_normal.jpg new file mode 100644 index 000000000..65d66ae6a Binary files /dev/null and b/apps/editor/public/material/flooring/green_labradorite/green_labradorite_normal.jpg differ diff --git a/apps/editor/public/material/flooring/green_labradorite/green_labradorite_specular.jpg b/apps/editor/public/material/flooring/green_labradorite/green_labradorite_specular.jpg new file mode 100644 index 000000000..58322be20 Binary files /dev/null and b/apps/editor/public/material/flooring/green_labradorite/green_labradorite_specular.jpg differ diff --git a/apps/editor/public/material/flooring/ground_earth/ground_earth_ambientocclusion.jpg b/apps/editor/public/material/flooring/ground_earth/ground_earth_ambientocclusion.jpg new file mode 100644 index 000000000..54203ae7b Binary files /dev/null and b/apps/editor/public/material/flooring/ground_earth/ground_earth_ambientocclusion.jpg differ diff --git a/apps/editor/public/material/flooring/ground_earth/ground_earth_basecolor.jpg b/apps/editor/public/material/flooring/ground_earth/ground_earth_basecolor.jpg new file mode 100644 index 000000000..3ba2f4a3a Binary files /dev/null and b/apps/editor/public/material/flooring/ground_earth/ground_earth_basecolor.jpg differ diff --git a/apps/editor/public/material/flooring/ground_earth/ground_earth_height.jpg b/apps/editor/public/material/flooring/ground_earth/ground_earth_height.jpg new file mode 100644 index 000000000..08bafef0b Binary files /dev/null and b/apps/editor/public/material/flooring/ground_earth/ground_earth_height.jpg differ diff --git a/apps/editor/public/material/flooring/ground_earth/ground_earth_metallic.jpg b/apps/editor/public/material/flooring/ground_earth/ground_earth_metallic.jpg new file mode 100644 index 000000000..c18580801 Binary files /dev/null and b/apps/editor/public/material/flooring/ground_earth/ground_earth_metallic.jpg differ diff --git a/apps/editor/public/material/flooring/ground_earth/ground_earth_normal.jpg b/apps/editor/public/material/flooring/ground_earth/ground_earth_normal.jpg new file mode 100644 index 000000000..d82aed4fa Binary files /dev/null and b/apps/editor/public/material/flooring/ground_earth/ground_earth_normal.jpg differ diff --git a/apps/editor/public/material/flooring/ground_earth/ground_earth_roughness.jpg b/apps/editor/public/material/flooring/ground_earth/ground_earth_roughness.jpg new file mode 100644 index 000000000..3bdc24f63 Binary files /dev/null and b/apps/editor/public/material/flooring/ground_earth/ground_earth_roughness.jpg differ diff --git a/apps/editor/public/material/flooring/light_ceramic_grunge/light_ceramic_grunge_ambientocclusion.jpg b/apps/editor/public/material/flooring/light_ceramic_grunge/light_ceramic_grunge_ambientocclusion.jpg new file mode 100644 index 000000000..9eccf937a Binary files /dev/null and b/apps/editor/public/material/flooring/light_ceramic_grunge/light_ceramic_grunge_ambientocclusion.jpg differ diff --git a/apps/editor/public/material/flooring/light_ceramic_grunge/light_ceramic_grunge_basecolor.jpg b/apps/editor/public/material/flooring/light_ceramic_grunge/light_ceramic_grunge_basecolor.jpg new file mode 100644 index 000000000..113531319 Binary files /dev/null and b/apps/editor/public/material/flooring/light_ceramic_grunge/light_ceramic_grunge_basecolor.jpg differ diff --git a/apps/editor/public/material/flooring/light_ceramic_grunge/light_ceramic_grunge_height.jpg b/apps/editor/public/material/flooring/light_ceramic_grunge/light_ceramic_grunge_height.jpg new file mode 100644 index 000000000..5b8406efb Binary files /dev/null and b/apps/editor/public/material/flooring/light_ceramic_grunge/light_ceramic_grunge_height.jpg differ diff --git a/apps/editor/public/material/flooring/light_ceramic_grunge/light_ceramic_grunge_metallic.jpg b/apps/editor/public/material/flooring/light_ceramic_grunge/light_ceramic_grunge_metallic.jpg new file mode 100644 index 000000000..c18580801 Binary files /dev/null and b/apps/editor/public/material/flooring/light_ceramic_grunge/light_ceramic_grunge_metallic.jpg differ diff --git a/apps/editor/public/material/flooring/light_ceramic_grunge/light_ceramic_grunge_normal.jpg b/apps/editor/public/material/flooring/light_ceramic_grunge/light_ceramic_grunge_normal.jpg new file mode 100644 index 000000000..9a0cd9159 Binary files /dev/null and b/apps/editor/public/material/flooring/light_ceramic_grunge/light_ceramic_grunge_normal.jpg differ diff --git a/apps/editor/public/material/flooring/light_ceramic_grunge/light_ceramic_grunge_roughness.jpg b/apps/editor/public/material/flooring/light_ceramic_grunge/light_ceramic_grunge_roughness.jpg new file mode 100644 index 000000000..b96bf20c5 Binary files /dev/null and b/apps/editor/public/material/flooring/light_ceramic_grunge/light_ceramic_grunge_roughness.jpg differ diff --git a/apps/editor/public/material/flooring/pool_tiles/pool_tiles_ao.jpg b/apps/editor/public/material/flooring/pool_tiles/pool_tiles_ao.jpg new file mode 100644 index 000000000..f2f6fcd05 Binary files /dev/null and b/apps/editor/public/material/flooring/pool_tiles/pool_tiles_ao.jpg differ diff --git a/apps/editor/public/material/flooring/pool_tiles/pool_tiles_diffuse.jpg b/apps/editor/public/material/flooring/pool_tiles/pool_tiles_diffuse.jpg new file mode 100644 index 000000000..20b9c2f32 Binary files /dev/null and b/apps/editor/public/material/flooring/pool_tiles/pool_tiles_diffuse.jpg differ diff --git a/apps/editor/public/material/flooring/pool_tiles/pool_tiles_displacement.jpg b/apps/editor/public/material/flooring/pool_tiles/pool_tiles_displacement.jpg new file mode 100644 index 000000000..432e2f132 Binary files /dev/null and b/apps/editor/public/material/flooring/pool_tiles/pool_tiles_displacement.jpg differ diff --git a/apps/editor/public/material/flooring/pool_tiles/pool_tiles_normal.jpg b/apps/editor/public/material/flooring/pool_tiles/pool_tiles_normal.jpg new file mode 100644 index 000000000..f196c4802 Binary files /dev/null and b/apps/editor/public/material/flooring/pool_tiles/pool_tiles_normal.jpg differ diff --git a/apps/editor/public/material/flooring/pool_tiles/pool_tiles_specular.jpg b/apps/editor/public/material/flooring/pool_tiles/pool_tiles_specular.jpg new file mode 100644 index 000000000..56b70b668 Binary files /dev/null and b/apps/editor/public/material/flooring/pool_tiles/pool_tiles_specular.jpg differ diff --git a/apps/editor/public/material/flooring/statuaretto/statuaretto_ao.jpg b/apps/editor/public/material/flooring/statuaretto/statuaretto_ao.jpg new file mode 100644 index 000000000..bc5e4aff2 Binary files /dev/null and b/apps/editor/public/material/flooring/statuaretto/statuaretto_ao.jpg differ diff --git a/apps/editor/public/material/flooring/statuaretto/statuaretto_diffuse.jpg b/apps/editor/public/material/flooring/statuaretto/statuaretto_diffuse.jpg new file mode 100644 index 000000000..422c854ea Binary files /dev/null and b/apps/editor/public/material/flooring/statuaretto/statuaretto_diffuse.jpg differ diff --git a/apps/editor/public/material/flooring/statuaretto/statuaretto_displacement.jpg b/apps/editor/public/material/flooring/statuaretto/statuaretto_displacement.jpg new file mode 100644 index 000000000..3893d305e Binary files /dev/null and b/apps/editor/public/material/flooring/statuaretto/statuaretto_displacement.jpg differ diff --git a/apps/editor/public/material/flooring/statuaretto/statuaretto_normal.jpg b/apps/editor/public/material/flooring/statuaretto/statuaretto_normal.jpg new file mode 100644 index 000000000..d18bad5b1 Binary files /dev/null and b/apps/editor/public/material/flooring/statuaretto/statuaretto_normal.jpg differ diff --git a/apps/editor/public/material/flooring/statuaretto/statuaretto_specular.jpg b/apps/editor/public/material/flooring/statuaretto/statuaretto_specular.jpg new file mode 100644 index 000000000..faee49dc9 Binary files /dev/null and b/apps/editor/public/material/flooring/statuaretto/statuaretto_specular.jpg differ diff --git a/apps/editor/public/material/flooring/stone_wall/stone_wall_ao.webp b/apps/editor/public/material/flooring/stone_wall/stone_wall_ao.webp new file mode 100644 index 000000000..8cf781b71 Binary files /dev/null and b/apps/editor/public/material/flooring/stone_wall/stone_wall_ao.webp differ diff --git a/apps/editor/public/material/flooring/stone_wall/stone_wall_diffuse.webp b/apps/editor/public/material/flooring/stone_wall/stone_wall_diffuse.webp new file mode 100644 index 000000000..f290c8031 Binary files /dev/null and b/apps/editor/public/material/flooring/stone_wall/stone_wall_diffuse.webp differ diff --git a/apps/editor/public/material/flooring/stone_wall/stone_wall_displacement.webp b/apps/editor/public/material/flooring/stone_wall/stone_wall_displacement.webp new file mode 100644 index 000000000..1403b9470 Binary files /dev/null and b/apps/editor/public/material/flooring/stone_wall/stone_wall_displacement.webp differ diff --git a/apps/editor/public/material/flooring/stone_wall/stone_wall_normal.webp b/apps/editor/public/material/flooring/stone_wall/stone_wall_normal.webp new file mode 100644 index 000000000..6ea99c842 Binary files /dev/null and b/apps/editor/public/material/flooring/stone_wall/stone_wall_normal.webp differ diff --git a/apps/editor/public/material/flooring/stone_wall/stone_wall_specular.webp b/apps/editor/public/material/flooring/stone_wall/stone_wall_specular.webp new file mode 100644 index 000000000..baa524385 Binary files /dev/null and b/apps/editor/public/material/flooring/stone_wall/stone_wall_specular.webp differ diff --git a/apps/editor/public/material/flooring/terrazzo/terrazzo_basecolor.jpg b/apps/editor/public/material/flooring/terrazzo/terrazzo_basecolor.jpg new file mode 100644 index 000000000..af4b558b3 Binary files /dev/null and b/apps/editor/public/material/flooring/terrazzo/terrazzo_basecolor.jpg differ diff --git a/apps/editor/public/material/flooring/terrazzo/terrazzo_height.jpg b/apps/editor/public/material/flooring/terrazzo/terrazzo_height.jpg new file mode 100644 index 000000000..ab9c5ecb5 Binary files /dev/null and b/apps/editor/public/material/flooring/terrazzo/terrazzo_height.jpg differ diff --git a/apps/editor/public/material/flooring/terrazzo/terrazzo_mask.jpg b/apps/editor/public/material/flooring/terrazzo/terrazzo_mask.jpg new file mode 100644 index 000000000..84cf3c55d Binary files /dev/null and b/apps/editor/public/material/flooring/terrazzo/terrazzo_mask.jpg differ diff --git a/apps/editor/public/material/flooring/terrazzo/terrazzo_metallic.jpg b/apps/editor/public/material/flooring/terrazzo/terrazzo_metallic.jpg new file mode 100644 index 000000000..c18580801 Binary files /dev/null and b/apps/editor/public/material/flooring/terrazzo/terrazzo_metallic.jpg differ diff --git a/apps/editor/public/material/flooring/terrazzo/terrazzo_normal.jpg b/apps/editor/public/material/flooring/terrazzo/terrazzo_normal.jpg new file mode 100644 index 000000000..365a7e28e Binary files /dev/null and b/apps/editor/public/material/flooring/terrazzo/terrazzo_normal.jpg differ diff --git a/apps/editor/public/material/flooring/terrazzo/terrazzo_roughness.jpg b/apps/editor/public/material/flooring/terrazzo/terrazzo_roughness.jpg new file mode 100644 index 000000000..439bedbf0 Binary files /dev/null and b/apps/editor/public/material/flooring/terrazzo/terrazzo_roughness.jpg differ diff --git a/apps/editor/public/material/flooring/tile_mosaic/tile_mosaic_ambientocclusion.webp b/apps/editor/public/material/flooring/tile_mosaic/tile_mosaic_ambientocclusion.webp new file mode 100644 index 000000000..ac19940f3 Binary files /dev/null and b/apps/editor/public/material/flooring/tile_mosaic/tile_mosaic_ambientocclusion.webp differ diff --git a/apps/editor/public/material/flooring/tile_mosaic/tile_mosaic_basecolor.webp b/apps/editor/public/material/flooring/tile_mosaic/tile_mosaic_basecolor.webp new file mode 100644 index 000000000..01213e0eb Binary files /dev/null and b/apps/editor/public/material/flooring/tile_mosaic/tile_mosaic_basecolor.webp differ diff --git a/apps/editor/public/material/flooring/tile_mosaic/tile_mosaic_height.webp b/apps/editor/public/material/flooring/tile_mosaic/tile_mosaic_height.webp new file mode 100644 index 000000000..1db0c9c78 Binary files /dev/null and b/apps/editor/public/material/flooring/tile_mosaic/tile_mosaic_height.webp differ diff --git a/apps/editor/public/material/flooring/tile_mosaic/tile_mosaic_metallic.webp b/apps/editor/public/material/flooring/tile_mosaic/tile_mosaic_metallic.webp new file mode 100644 index 000000000..269e23a78 Binary files /dev/null and b/apps/editor/public/material/flooring/tile_mosaic/tile_mosaic_metallic.webp differ diff --git a/apps/editor/public/material/flooring/tile_mosaic/tile_mosaic_normal.webp b/apps/editor/public/material/flooring/tile_mosaic/tile_mosaic_normal.webp new file mode 100644 index 000000000..8ac27d79b Binary files /dev/null and b/apps/editor/public/material/flooring/tile_mosaic/tile_mosaic_normal.webp differ diff --git a/apps/editor/public/material/flooring/tile_mosaic/tile_mosaic_roughness.webp b/apps/editor/public/material/flooring/tile_mosaic/tile_mosaic_roughness.webp new file mode 100644 index 000000000..484aee344 Binary files /dev/null and b/apps/editor/public/material/flooring/tile_mosaic/tile_mosaic_roughness.webp differ diff --git a/apps/editor/public/material/flooring/tile_pattern/tile_pattern_ambientocclusion.webp b/apps/editor/public/material/flooring/tile_pattern/tile_pattern_ambientocclusion.webp new file mode 100644 index 000000000..4a4b29882 Binary files /dev/null and b/apps/editor/public/material/flooring/tile_pattern/tile_pattern_ambientocclusion.webp differ diff --git a/apps/editor/public/material/flooring/tile_pattern/tile_pattern_basecolor.webp b/apps/editor/public/material/flooring/tile_pattern/tile_pattern_basecolor.webp new file mode 100644 index 000000000..cccb67402 Binary files /dev/null and b/apps/editor/public/material/flooring/tile_pattern/tile_pattern_basecolor.webp differ diff --git a/apps/editor/public/material/flooring/tile_pattern/tile_pattern_height.webp b/apps/editor/public/material/flooring/tile_pattern/tile_pattern_height.webp new file mode 100644 index 000000000..88de29084 Binary files /dev/null and b/apps/editor/public/material/flooring/tile_pattern/tile_pattern_height.webp differ diff --git a/apps/editor/public/material/flooring/tile_pattern/tile_pattern_jointmask.webp b/apps/editor/public/material/flooring/tile_pattern/tile_pattern_jointmask.webp new file mode 100644 index 000000000..2ec006d14 Binary files /dev/null and b/apps/editor/public/material/flooring/tile_pattern/tile_pattern_jointmask.webp differ diff --git a/apps/editor/public/material/flooring/tile_pattern/tile_pattern_metallic.webp b/apps/editor/public/material/flooring/tile_pattern/tile_pattern_metallic.webp new file mode 100644 index 000000000..269e23a78 Binary files /dev/null and b/apps/editor/public/material/flooring/tile_pattern/tile_pattern_metallic.webp differ diff --git a/apps/editor/public/material/flooring/tile_pattern/tile_pattern_normal.webp b/apps/editor/public/material/flooring/tile_pattern/tile_pattern_normal.webp new file mode 100644 index 000000000..1e2341e67 Binary files /dev/null and b/apps/editor/public/material/flooring/tile_pattern/tile_pattern_normal.webp differ diff --git a/apps/editor/public/material/flooring/tile_pattern/tile_pattern_roughness.webp b/apps/editor/public/material/flooring/tile_pattern/tile_pattern_roughness.webp new file mode 100644 index 000000000..ae6d9b01c Binary files /dev/null and b/apps/editor/public/material/flooring/tile_pattern/tile_pattern_roughness.webp differ diff --git a/apps/editor/public/material/flooring/tile_quarry/tile_quarry_ambientocclusion.webp b/apps/editor/public/material/flooring/tile_quarry/tile_quarry_ambientocclusion.webp new file mode 100644 index 000000000..f993a4b6f Binary files /dev/null and b/apps/editor/public/material/flooring/tile_quarry/tile_quarry_ambientocclusion.webp differ diff --git a/apps/editor/public/material/flooring/tile_quarry/tile_quarry_basecolor.webp b/apps/editor/public/material/flooring/tile_quarry/tile_quarry_basecolor.webp new file mode 100644 index 000000000..e5e200d7f Binary files /dev/null and b/apps/editor/public/material/flooring/tile_quarry/tile_quarry_basecolor.webp differ diff --git a/apps/editor/public/material/flooring/tile_quarry/tile_quarry_height.webp b/apps/editor/public/material/flooring/tile_quarry/tile_quarry_height.webp new file mode 100644 index 000000000..382425ca7 Binary files /dev/null and b/apps/editor/public/material/flooring/tile_quarry/tile_quarry_height.webp differ diff --git a/apps/editor/public/material/flooring/tile_quarry/tile_quarry_mortar.webp b/apps/editor/public/material/flooring/tile_quarry/tile_quarry_mortar.webp new file mode 100644 index 000000000..2f53f2646 Binary files /dev/null and b/apps/editor/public/material/flooring/tile_quarry/tile_quarry_mortar.webp differ diff --git a/apps/editor/public/material/flooring/tile_quarry/tile_quarry_normal.webp b/apps/editor/public/material/flooring/tile_quarry/tile_quarry_normal.webp new file mode 100644 index 000000000..7a6e3c296 Binary files /dev/null and b/apps/editor/public/material/flooring/tile_quarry/tile_quarry_normal.webp differ diff --git a/apps/editor/public/material/flooring/tile_quarry/tile_quarry_roughness.webp b/apps/editor/public/material/flooring/tile_quarry/tile_quarry_roughness.webp new file mode 100644 index 000000000..9570f0527 Binary files /dev/null and b/apps/editor/public/material/flooring/tile_quarry/tile_quarry_roughness.webp differ diff --git a/apps/editor/public/material/flooring/tile_stone/tile_stone_ambientocclusion.webp b/apps/editor/public/material/flooring/tile_stone/tile_stone_ambientocclusion.webp new file mode 100644 index 000000000..562590b28 Binary files /dev/null and b/apps/editor/public/material/flooring/tile_stone/tile_stone_ambientocclusion.webp differ diff --git a/apps/editor/public/material/flooring/tile_stone/tile_stone_basecolor.webp b/apps/editor/public/material/flooring/tile_stone/tile_stone_basecolor.webp new file mode 100644 index 000000000..c04ad9945 Binary files /dev/null and b/apps/editor/public/material/flooring/tile_stone/tile_stone_basecolor.webp differ diff --git a/apps/editor/public/material/flooring/tile_stone/tile_stone_height.webp b/apps/editor/public/material/flooring/tile_stone/tile_stone_height.webp new file mode 100644 index 000000000..089cf12ec Binary files /dev/null and b/apps/editor/public/material/flooring/tile_stone/tile_stone_height.webp differ diff --git a/apps/editor/public/material/flooring/tile_stone/tile_stone_mortar.webp b/apps/editor/public/material/flooring/tile_stone/tile_stone_mortar.webp new file mode 100644 index 000000000..daac16ad3 Binary files /dev/null and b/apps/editor/public/material/flooring/tile_stone/tile_stone_mortar.webp differ diff --git a/apps/editor/public/material/flooring/tile_stone/tile_stone_normal.webp b/apps/editor/public/material/flooring/tile_stone/tile_stone_normal.webp new file mode 100644 index 000000000..94601e784 Binary files /dev/null and b/apps/editor/public/material/flooring/tile_stone/tile_stone_normal.webp differ diff --git a/apps/editor/public/material/flooring/tile_stone/tile_stone_roughness.webp b/apps/editor/public/material/flooring/tile_stone/tile_stone_roughness.webp new file mode 100644 index 000000000..7691448a6 Binary files /dev/null and b/apps/editor/public/material/flooring/tile_stone/tile_stone_roughness.webp differ diff --git a/apps/editor/public/material/flooring/tile_terracotta/tile_terracotta_ambientocclusion.webp b/apps/editor/public/material/flooring/tile_terracotta/tile_terracotta_ambientocclusion.webp new file mode 100644 index 000000000..f2c815678 Binary files /dev/null and b/apps/editor/public/material/flooring/tile_terracotta/tile_terracotta_ambientocclusion.webp differ diff --git a/apps/editor/public/material/flooring/tile_terracotta/tile_terracotta_basecolor.webp b/apps/editor/public/material/flooring/tile_terracotta/tile_terracotta_basecolor.webp new file mode 100644 index 000000000..0d1abcf64 Binary files /dev/null and b/apps/editor/public/material/flooring/tile_terracotta/tile_terracotta_basecolor.webp differ diff --git a/apps/editor/public/material/flooring/tile_terracotta/tile_terracotta_height.webp b/apps/editor/public/material/flooring/tile_terracotta/tile_terracotta_height.webp new file mode 100644 index 000000000..570e9fa7e Binary files /dev/null and b/apps/editor/public/material/flooring/tile_terracotta/tile_terracotta_height.webp differ diff --git a/apps/editor/public/material/flooring/tile_terracotta/tile_terracotta_mortar.webp b/apps/editor/public/material/flooring/tile_terracotta/tile_terracotta_mortar.webp new file mode 100644 index 000000000..16e077780 Binary files /dev/null and b/apps/editor/public/material/flooring/tile_terracotta/tile_terracotta_mortar.webp differ diff --git a/apps/editor/public/material/flooring/tile_terracotta/tile_terracotta_normal.webp b/apps/editor/public/material/flooring/tile_terracotta/tile_terracotta_normal.webp new file mode 100644 index 000000000..b35a754c0 Binary files /dev/null and b/apps/editor/public/material/flooring/tile_terracotta/tile_terracotta_normal.webp differ diff --git a/apps/editor/public/material/flooring/tile_terracotta/tile_terracotta_roughness.webp b/apps/editor/public/material/flooring/tile_terracotta/tile_terracotta_roughness.webp new file mode 100644 index 000000000..8f3dc95d1 Binary files /dev/null and b/apps/editor/public/material/flooring/tile_terracotta/tile_terracotta_roughness.webp differ diff --git a/apps/editor/public/material/flooring/tiles_checker/tiles_checker_ao.jpg b/apps/editor/public/material/flooring/tiles_checker/tiles_checker_ao.jpg new file mode 100644 index 000000000..7603e0d64 Binary files /dev/null and b/apps/editor/public/material/flooring/tiles_checker/tiles_checker_ao.jpg differ diff --git a/apps/editor/public/material/flooring/tiles_checker/tiles_checker_diffuse.jpg b/apps/editor/public/material/flooring/tiles_checker/tiles_checker_diffuse.jpg new file mode 100644 index 000000000..7af42876e Binary files /dev/null and b/apps/editor/public/material/flooring/tiles_checker/tiles_checker_diffuse.jpg differ diff --git a/apps/editor/public/material/flooring/tiles_checker/tiles_checker_displacement.jpg b/apps/editor/public/material/flooring/tiles_checker/tiles_checker_displacement.jpg new file mode 100644 index 000000000..d3fcf9979 Binary files /dev/null and b/apps/editor/public/material/flooring/tiles_checker/tiles_checker_displacement.jpg differ diff --git a/apps/editor/public/material/flooring/tiles_checker/tiles_checker_normal.jpg b/apps/editor/public/material/flooring/tiles_checker/tiles_checker_normal.jpg new file mode 100644 index 000000000..981de952e Binary files /dev/null and b/apps/editor/public/material/flooring/tiles_checker/tiles_checker_normal.jpg differ diff --git a/apps/editor/public/material/flooring/tiles_checker/tiles_checker_specular.jpg b/apps/editor/public/material/flooring/tiles_checker/tiles_checker_specular.jpg new file mode 100644 index 000000000..dce524d1a Binary files /dev/null and b/apps/editor/public/material/flooring/tiles_checker/tiles_checker_specular.jpg differ diff --git a/apps/editor/public/material/flooring/tiles_grid/tiles_grid_ao.jpg b/apps/editor/public/material/flooring/tiles_grid/tiles_grid_ao.jpg new file mode 100644 index 000000000..1e5d0a9da Binary files /dev/null and b/apps/editor/public/material/flooring/tiles_grid/tiles_grid_ao.jpg differ diff --git a/apps/editor/public/material/flooring/tiles_grid/tiles_grid_diffuse.jpg b/apps/editor/public/material/flooring/tiles_grid/tiles_grid_diffuse.jpg new file mode 100644 index 000000000..3b3d551c5 Binary files /dev/null and b/apps/editor/public/material/flooring/tiles_grid/tiles_grid_diffuse.jpg differ diff --git a/apps/editor/public/material/flooring/tiles_grid/tiles_grid_displacement.jpg b/apps/editor/public/material/flooring/tiles_grid/tiles_grid_displacement.jpg new file mode 100644 index 000000000..a485c13f5 Binary files /dev/null and b/apps/editor/public/material/flooring/tiles_grid/tiles_grid_displacement.jpg differ diff --git a/apps/editor/public/material/flooring/tiles_grid/tiles_grid_normal.jpg b/apps/editor/public/material/flooring/tiles_grid/tiles_grid_normal.jpg new file mode 100644 index 000000000..401884fa3 Binary files /dev/null and b/apps/editor/public/material/flooring/tiles_grid/tiles_grid_normal.jpg differ diff --git a/apps/editor/public/material/flooring/tiles_grid/tiles_grid_specular.jpg b/apps/editor/public/material/flooring/tiles_grid/tiles_grid_specular.jpg new file mode 100644 index 000000000..12c6268e6 Binary files /dev/null and b/apps/editor/public/material/flooring/tiles_grid/tiles_grid_specular.jpg differ diff --git a/apps/editor/public/material/flooring/wooden_ceramic_2/wooden_ceramic-ao.webp b/apps/editor/public/material/flooring/wooden_ceramic_2/wooden_ceramic-ao.webp new file mode 100644 index 000000000..722fe97a3 Binary files /dev/null and b/apps/editor/public/material/flooring/wooden_ceramic_2/wooden_ceramic-ao.webp differ diff --git a/apps/editor/public/material/flooring/wooden_ceramic_2/wooden_ceramic-diffuse.webp b/apps/editor/public/material/flooring/wooden_ceramic_2/wooden_ceramic-diffuse.webp new file mode 100644 index 000000000..f9174ebfb Binary files /dev/null and b/apps/editor/public/material/flooring/wooden_ceramic_2/wooden_ceramic-diffuse.webp differ diff --git a/apps/editor/public/material/flooring/wooden_ceramic_2/wooden_ceramic-displacement.webp b/apps/editor/public/material/flooring/wooden_ceramic_2/wooden_ceramic-displacement.webp new file mode 100644 index 000000000..9a1e5d59b Binary files /dev/null and b/apps/editor/public/material/flooring/wooden_ceramic_2/wooden_ceramic-displacement.webp differ diff --git a/apps/editor/public/material/flooring/wooden_ceramic_2/wooden_ceramic-normal.webp b/apps/editor/public/material/flooring/wooden_ceramic_2/wooden_ceramic-normal.webp new file mode 100644 index 000000000..d23c647da Binary files /dev/null and b/apps/editor/public/material/flooring/wooden_ceramic_2/wooden_ceramic-normal.webp differ diff --git a/apps/editor/public/material/flooring/wooden_ceramic_2/wooden_ceramic-specular.webp b/apps/editor/public/material/flooring/wooden_ceramic_2/wooden_ceramic-specular.webp new file mode 100644 index 000000000..228bb595e Binary files /dev/null and b/apps/editor/public/material/flooring/wooden_ceramic_2/wooden_ceramic-specular.webp differ diff --git a/apps/editor/public/material/flooring/wooden_ceramic_3/wooden_ceramic-ao.webp b/apps/editor/public/material/flooring/wooden_ceramic_3/wooden_ceramic-ao.webp new file mode 100644 index 000000000..92be58077 Binary files /dev/null and b/apps/editor/public/material/flooring/wooden_ceramic_3/wooden_ceramic-ao.webp differ diff --git a/apps/editor/public/material/flooring/wooden_ceramic_3/wooden_ceramic-diffuse.webp b/apps/editor/public/material/flooring/wooden_ceramic_3/wooden_ceramic-diffuse.webp new file mode 100644 index 000000000..25ff3e2e9 Binary files /dev/null and b/apps/editor/public/material/flooring/wooden_ceramic_3/wooden_ceramic-diffuse.webp differ diff --git a/apps/editor/public/material/flooring/wooden_ceramic_3/wooden_ceramic-displacement.webp b/apps/editor/public/material/flooring/wooden_ceramic_3/wooden_ceramic-displacement.webp new file mode 100644 index 000000000..e122f42c1 Binary files /dev/null and b/apps/editor/public/material/flooring/wooden_ceramic_3/wooden_ceramic-displacement.webp differ diff --git a/apps/editor/public/material/flooring/wooden_ceramic_3/wooden_ceramic-normal.webp b/apps/editor/public/material/flooring/wooden_ceramic_3/wooden_ceramic-normal.webp new file mode 100644 index 000000000..35b13187f Binary files /dev/null and b/apps/editor/public/material/flooring/wooden_ceramic_3/wooden_ceramic-normal.webp differ diff --git a/apps/editor/public/material/flooring/wooden_ceramic_3/wooden_ceramic-specular.webp b/apps/editor/public/material/flooring/wooden_ceramic_3/wooden_ceramic-specular.webp new file mode 100644 index 000000000..ebc81855f Binary files /dev/null and b/apps/editor/public/material/flooring/wooden_ceramic_3/wooden_ceramic-specular.webp differ diff --git a/apps/editor/public/material/flooring/woodparquet/woodparquet_ambientocclusion.webp b/apps/editor/public/material/flooring/woodparquet/woodparquet_ambientocclusion.webp new file mode 100644 index 000000000..9d745ab8e Binary files /dev/null and b/apps/editor/public/material/flooring/woodparquet/woodparquet_ambientocclusion.webp differ diff --git a/apps/editor/public/material/flooring/woodparquet/woodparquet_basecolor.webp b/apps/editor/public/material/flooring/woodparquet/woodparquet_basecolor.webp new file mode 100644 index 000000000..99d07a65c Binary files /dev/null and b/apps/editor/public/material/flooring/woodparquet/woodparquet_basecolor.webp differ diff --git a/apps/editor/public/material/flooring/woodparquet/woodparquet_height.webp b/apps/editor/public/material/flooring/woodparquet/woodparquet_height.webp new file mode 100644 index 000000000..583886a9e Binary files /dev/null and b/apps/editor/public/material/flooring/woodparquet/woodparquet_height.webp differ diff --git a/apps/editor/public/material/flooring/woodparquet/woodparquet_metallic.webp b/apps/editor/public/material/flooring/woodparquet/woodparquet_metallic.webp new file mode 100644 index 000000000..269e23a78 Binary files /dev/null and b/apps/editor/public/material/flooring/woodparquet/woodparquet_metallic.webp differ diff --git a/apps/editor/public/material/flooring/woodparquet/woodparquet_normal.webp b/apps/editor/public/material/flooring/woodparquet/woodparquet_normal.webp new file mode 100644 index 000000000..6f8308011 Binary files /dev/null and b/apps/editor/public/material/flooring/woodparquet/woodparquet_normal.webp differ diff --git a/apps/editor/public/material/flooring/woodparquet/woodparquet_roughness.webp b/apps/editor/public/material/flooring/woodparquet/woodparquet_roughness.webp new file mode 100644 index 000000000..e34b496ce Binary files /dev/null and b/apps/editor/public/material/flooring/woodparquet/woodparquet_roughness.webp differ diff --git a/apps/editor/public/material/granite1/albedoMap_Granite.jpg b/apps/editor/public/material/granite1/albedoMap_Granite.jpg deleted file mode 100644 index eb8dcc4fb..000000000 Binary files a/apps/editor/public/material/granite1/albedoMap_Granite.jpg and /dev/null differ diff --git a/apps/editor/public/material/granite1/granite_thumbnail.webp b/apps/editor/public/material/granite1/granite_thumbnail.webp deleted file mode 100644 index 850ba54d5..000000000 Binary files a/apps/editor/public/material/granite1/granite_thumbnail.webp and /dev/null differ diff --git a/apps/editor/public/material/marble1/albedoMap_marble.jpg b/apps/editor/public/material/marble1/albedoMap_marble.jpg deleted file mode 100644 index 3e2702e16..000000000 Binary files a/apps/editor/public/material/marble1/albedoMap_marble.jpg and /dev/null differ diff --git a/apps/editor/public/material/marble1/marble1_thumbnail.webp b/apps/editor/public/material/marble1/marble1_thumbnail.webp deleted file mode 100644 index e90e9294f..000000000 Binary files a/apps/editor/public/material/marble1/marble1_thumbnail.webp and /dev/null differ diff --git a/apps/editor/public/material/marble2/albedoMap_marble.jpg b/apps/editor/public/material/marble2/albedoMap_marble.jpg deleted file mode 100644 index c30fac623..000000000 Binary files a/apps/editor/public/material/marble2/albedoMap_marble.jpg and /dev/null differ diff --git a/apps/editor/public/material/marble2/marble2_thumbnail.webp b/apps/editor/public/material/marble2/marble2_thumbnail.webp deleted file mode 100644 index 3a60c6cba..000000000 Binary files a/apps/editor/public/material/marble2/marble2_thumbnail.webp and /dev/null differ diff --git a/apps/editor/public/material/parquet1/albedoMap_parquet.jpg b/apps/editor/public/material/parquet1/albedoMap_parquet.jpg deleted file mode 100644 index bd84dcc92..000000000 Binary files a/apps/editor/public/material/parquet1/albedoMap_parquet.jpg and /dev/null differ diff --git a/apps/editor/public/material/parquet1/parquet_thumnail.webp b/apps/editor/public/material/parquet1/parquet_thumnail.webp deleted file mode 100644 index b6ab1ec6e..000000000 Binary files a/apps/editor/public/material/parquet1/parquet_thumnail.webp and /dev/null differ diff --git a/apps/editor/public/material/parquet2/albedoMap_parquet.jpg b/apps/editor/public/material/parquet2/albedoMap_parquet.jpg deleted file mode 100644 index a658de2d9..000000000 Binary files a/apps/editor/public/material/parquet2/albedoMap_parquet.jpg and /dev/null differ diff --git a/apps/editor/public/material/parquet2/parquet2_thumbnail.webp b/apps/editor/public/material/parquet2/parquet2_thumbnail.webp deleted file mode 100644 index 0b811270b..000000000 Binary files a/apps/editor/public/material/parquet2/parquet2_thumbnail.webp and /dev/null differ diff --git a/apps/editor/public/material/roofing/roof_shingles_classic/roof_shingles_classic_ambientocclusion.webp b/apps/editor/public/material/roofing/roof_shingles_classic/roof_shingles_classic_ambientocclusion.webp new file mode 100644 index 000000000..f9f720797 Binary files /dev/null and b/apps/editor/public/material/roofing/roof_shingles_classic/roof_shingles_classic_ambientocclusion.webp differ diff --git a/apps/editor/public/material/roofing/roof_shingles_classic/roof_shingles_classic_basecolor.webp b/apps/editor/public/material/roofing/roof_shingles_classic/roof_shingles_classic_basecolor.webp new file mode 100644 index 000000000..43328559f Binary files /dev/null and b/apps/editor/public/material/roofing/roof_shingles_classic/roof_shingles_classic_basecolor.webp differ diff --git a/apps/editor/public/material/roofing/roof_shingles_classic/roof_shingles_classic_height.webp b/apps/editor/public/material/roofing/roof_shingles_classic/roof_shingles_classic_height.webp new file mode 100644 index 000000000..ba99a8131 Binary files /dev/null and b/apps/editor/public/material/roofing/roof_shingles_classic/roof_shingles_classic_height.webp differ diff --git a/apps/editor/public/material/roofing/roof_shingles_classic/roof_shingles_classic_metallic.webp b/apps/editor/public/material/roofing/roof_shingles_classic/roof_shingles_classic_metallic.webp new file mode 100644 index 000000000..824b142ec Binary files /dev/null and b/apps/editor/public/material/roofing/roof_shingles_classic/roof_shingles_classic_metallic.webp differ diff --git a/apps/editor/public/material/roofing/roof_shingles_classic/roof_shingles_classic_normal.webp b/apps/editor/public/material/roofing/roof_shingles_classic/roof_shingles_classic_normal.webp new file mode 100644 index 000000000..f30590854 Binary files /dev/null and b/apps/editor/public/material/roofing/roof_shingles_classic/roof_shingles_classic_normal.webp differ diff --git a/apps/editor/public/material/roofing/roof_shingles_classic/roof_shingles_classic_roughness.webp b/apps/editor/public/material/roofing/roof_shingles_classic/roof_shingles_classic_roughness.webp new file mode 100644 index 000000000..eab36008f Binary files /dev/null and b/apps/editor/public/material/roofing/roof_shingles_classic/roof_shingles_classic_roughness.webp differ diff --git a/apps/editor/public/material/roofing/roof_shingles_weathered/roof_shingles_weathered_ambientocclusion.webp b/apps/editor/public/material/roofing/roof_shingles_weathered/roof_shingles_weathered_ambientocclusion.webp new file mode 100644 index 000000000..95fd2da34 Binary files /dev/null and b/apps/editor/public/material/roofing/roof_shingles_weathered/roof_shingles_weathered_ambientocclusion.webp differ diff --git a/apps/editor/public/material/roofing/roof_shingles_weathered/roof_shingles_weathered_basecolor.webp b/apps/editor/public/material/roofing/roof_shingles_weathered/roof_shingles_weathered_basecolor.webp new file mode 100644 index 000000000..1872a1ccc Binary files /dev/null and b/apps/editor/public/material/roofing/roof_shingles_weathered/roof_shingles_weathered_basecolor.webp differ diff --git a/apps/editor/public/material/roofing/roof_shingles_weathered/roof_shingles_weathered_height.webp b/apps/editor/public/material/roofing/roof_shingles_weathered/roof_shingles_weathered_height.webp new file mode 100644 index 000000000..ba7101326 Binary files /dev/null and b/apps/editor/public/material/roofing/roof_shingles_weathered/roof_shingles_weathered_height.webp differ diff --git a/apps/editor/public/material/roofing/roof_shingles_weathered/roof_shingles_weathered_metallic.webp b/apps/editor/public/material/roofing/roof_shingles_weathered/roof_shingles_weathered_metallic.webp new file mode 100644 index 000000000..269e23a78 Binary files /dev/null and b/apps/editor/public/material/roofing/roof_shingles_weathered/roof_shingles_weathered_metallic.webp differ diff --git a/apps/editor/public/material/roofing/roof_shingles_weathered/roof_shingles_weathered_normal.webp b/apps/editor/public/material/roofing/roof_shingles_weathered/roof_shingles_weathered_normal.webp new file mode 100644 index 000000000..e45075a86 Binary files /dev/null and b/apps/editor/public/material/roofing/roof_shingles_weathered/roof_shingles_weathered_normal.webp differ diff --git a/apps/editor/public/material/roofing/roof_shingles_weathered/roof_shingles_weathered_roughness.webp b/apps/editor/public/material/roofing/roof_shingles_weathered/roof_shingles_weathered_roughness.webp new file mode 100644 index 000000000..d88a07be2 Binary files /dev/null and b/apps/editor/public/material/roofing/roof_shingles_weathered/roof_shingles_weathered_roughness.webp differ diff --git a/apps/editor/public/material/roofing/roof_tiles_clay/roof_tiles_clay_ambientocclusion.webp b/apps/editor/public/material/roofing/roof_tiles_clay/roof_tiles_clay_ambientocclusion.webp new file mode 100644 index 000000000..61789987b Binary files /dev/null and b/apps/editor/public/material/roofing/roof_tiles_clay/roof_tiles_clay_ambientocclusion.webp differ diff --git a/apps/editor/public/material/roofing/roof_tiles_clay/roof_tiles_clay_basecolor.webp b/apps/editor/public/material/roofing/roof_tiles_clay/roof_tiles_clay_basecolor.webp new file mode 100644 index 000000000..b5b94edf6 Binary files /dev/null and b/apps/editor/public/material/roofing/roof_tiles_clay/roof_tiles_clay_basecolor.webp differ diff --git a/apps/editor/public/material/roofing/roof_tiles_clay/roof_tiles_clay_height.webp b/apps/editor/public/material/roofing/roof_tiles_clay/roof_tiles_clay_height.webp new file mode 100644 index 000000000..276cb84e6 Binary files /dev/null and b/apps/editor/public/material/roofing/roof_tiles_clay/roof_tiles_clay_height.webp differ diff --git a/apps/editor/public/material/roofing/roof_tiles_clay/roof_tiles_clay_metallic.png b/apps/editor/public/material/roofing/roof_tiles_clay/roof_tiles_clay_metallic.png new file mode 100644 index 000000000..852a00aa6 Binary files /dev/null and b/apps/editor/public/material/roofing/roof_tiles_clay/roof_tiles_clay_metallic.png differ diff --git a/apps/editor/public/material/roofing/roof_tiles_clay/roof_tiles_clay_normal.webp b/apps/editor/public/material/roofing/roof_tiles_clay/roof_tiles_clay_normal.webp new file mode 100644 index 000000000..4c3df1473 Binary files /dev/null and b/apps/editor/public/material/roofing/roof_tiles_clay/roof_tiles_clay_normal.webp differ diff --git a/apps/editor/public/material/roofing/roof_tiles_clay/roof_tiles_clay_roughness.webp b/apps/editor/public/material/roofing/roof_tiles_clay/roof_tiles_clay_roughness.webp new file mode 100644 index 000000000..5058f8224 Binary files /dev/null and b/apps/editor/public/material/roofing/roof_tiles_clay/roof_tiles_clay_roughness.webp differ diff --git a/apps/editor/public/material/roofing/roof_tiles_terracotta/roof_tiles_terracotta_ambientocclusion.webp b/apps/editor/public/material/roofing/roof_tiles_terracotta/roof_tiles_terracotta_ambientocclusion.webp new file mode 100644 index 000000000..d88f47f2f Binary files /dev/null and b/apps/editor/public/material/roofing/roof_tiles_terracotta/roof_tiles_terracotta_ambientocclusion.webp differ diff --git a/apps/editor/public/material/roofing/roof_tiles_terracotta/roof_tiles_terracotta_basecolor.webp b/apps/editor/public/material/roofing/roof_tiles_terracotta/roof_tiles_terracotta_basecolor.webp new file mode 100644 index 000000000..8a0127c76 Binary files /dev/null and b/apps/editor/public/material/roofing/roof_tiles_terracotta/roof_tiles_terracotta_basecolor.webp differ diff --git a/apps/editor/public/material/roofing/roof_tiles_terracotta/roof_tiles_terracotta_height.webp b/apps/editor/public/material/roofing/roof_tiles_terracotta/roof_tiles_terracotta_height.webp new file mode 100644 index 000000000..08c2881d1 Binary files /dev/null and b/apps/editor/public/material/roofing/roof_tiles_terracotta/roof_tiles_terracotta_height.webp differ diff --git a/apps/editor/public/material/roofing/roof_tiles_terracotta/roof_tiles_terracotta_metallic.webp b/apps/editor/public/material/roofing/roof_tiles_terracotta/roof_tiles_terracotta_metallic.webp new file mode 100644 index 000000000..269e23a78 Binary files /dev/null and b/apps/editor/public/material/roofing/roof_tiles_terracotta/roof_tiles_terracotta_metallic.webp differ diff --git a/apps/editor/public/material/roofing/roof_tiles_terracotta/roof_tiles_terracotta_normal.webp b/apps/editor/public/material/roofing/roof_tiles_terracotta/roof_tiles_terracotta_normal.webp new file mode 100644 index 000000000..69de8a2d3 Binary files /dev/null and b/apps/editor/public/material/roofing/roof_tiles_terracotta/roof_tiles_terracotta_normal.webp differ diff --git a/apps/editor/public/material/roofing/roof_tiles_terracotta/roof_tiles_terracotta_roughness.webp b/apps/editor/public/material/roofing/roof_tiles_terracotta/roof_tiles_terracotta_roughness.webp new file mode 100644 index 000000000..1632d7fde Binary files /dev/null and b/apps/editor/public/material/roofing/roof_tiles_terracotta/roof_tiles_terracotta_roughness.webp differ diff --git a/apps/editor/public/material/wallpaper1/albedoMap_1.webp b/apps/editor/public/material/wallpaper1/albedoMap_1.webp deleted file mode 100644 index f22d01ffe..000000000 Binary files a/apps/editor/public/material/wallpaper1/albedoMap_1.webp and /dev/null differ diff --git a/apps/editor/public/material/wallpaper1/normalMap_NormalMap.webp b/apps/editor/public/material/wallpaper1/normalMap_NormalMap.webp deleted file mode 100644 index 54bd308ca..000000000 Binary files a/apps/editor/public/material/wallpaper1/normalMap_NormalMap.webp and /dev/null differ diff --git a/apps/editor/public/material/wallpaper1/wallpaper1_thumbnail.webp b/apps/editor/public/material/wallpaper1/wallpaper1_thumbnail.webp deleted file mode 100644 index f8a7cdb3b..000000000 Binary files a/apps/editor/public/material/wallpaper1/wallpaper1_thumbnail.webp and /dev/null differ diff --git a/apps/editor/public/material/wallpaper2/albedoMap_5.webp b/apps/editor/public/material/wallpaper2/albedoMap_5.webp deleted file mode 100644 index 255c55db2..000000000 Binary files a/apps/editor/public/material/wallpaper2/albedoMap_5.webp and /dev/null differ diff --git a/apps/editor/public/material/wallpaper2/wallpaper2_thumnail.webp b/apps/editor/public/material/wallpaper2/wallpaper2_thumnail.webp deleted file mode 100644 index 745e6b629..000000000 Binary files a/apps/editor/public/material/wallpaper2/wallpaper2_thumnail.webp and /dev/null differ diff --git a/apps/editor/public/material/wallpaper3/albedoMap_wallpaper3.avif b/apps/editor/public/material/wallpaper3/albedoMap_wallpaper3.avif deleted file mode 100644 index efc964538..000000000 Binary files a/apps/editor/public/material/wallpaper3/albedoMap_wallpaper3.avif and /dev/null differ diff --git a/apps/editor/public/material/wallpaper3/wallpaper3_thumbnail.webp b/apps/editor/public/material/wallpaper3/wallpaper3_thumbnail.webp deleted file mode 100644 index b1288a1ff..000000000 Binary files a/apps/editor/public/material/wallpaper3/wallpaper3_thumbnail.webp and /dev/null differ diff --git a/apps/editor/public/material/wood/finewood_27/finewood_27_ambientocclusion.webp b/apps/editor/public/material/wood/finewood_27/finewood_27_ambientocclusion.webp new file mode 100644 index 000000000..0799939b5 Binary files /dev/null and b/apps/editor/public/material/wood/finewood_27/finewood_27_ambientocclusion.webp differ diff --git a/apps/editor/public/material/wood/finewood_27/finewood_27_basecolor.webp b/apps/editor/public/material/wood/finewood_27/finewood_27_basecolor.webp new file mode 100644 index 000000000..12ecb67bd Binary files /dev/null and b/apps/editor/public/material/wood/finewood_27/finewood_27_basecolor.webp differ diff --git a/apps/editor/public/material/wood/finewood_27/finewood_27_height.webp b/apps/editor/public/material/wood/finewood_27/finewood_27_height.webp new file mode 100644 index 000000000..16bfb343b Binary files /dev/null and b/apps/editor/public/material/wood/finewood_27/finewood_27_height.webp differ diff --git a/apps/editor/public/material/wood/finewood_27/finewood_27_normal.webp b/apps/editor/public/material/wood/finewood_27/finewood_27_normal.webp new file mode 100644 index 000000000..f547aa760 Binary files /dev/null and b/apps/editor/public/material/wood/finewood_27/finewood_27_normal.webp differ diff --git a/apps/editor/public/material/wood/finewood_27/finewood_27_roughness.webp b/apps/editor/public/material/wood/finewood_27/finewood_27_roughness.webp new file mode 100644 index 000000000..9ade7d002 Binary files /dev/null and b/apps/editor/public/material/wood/finewood_27/finewood_27_roughness.webp differ diff --git a/apps/editor/public/material/wood/floor_plank_1/floor_plank-ao.webp b/apps/editor/public/material/wood/floor_plank_1/floor_plank-ao.webp new file mode 100644 index 000000000..e1c4feb40 Binary files /dev/null and b/apps/editor/public/material/wood/floor_plank_1/floor_plank-ao.webp differ diff --git a/apps/editor/public/material/wood/floor_plank_1/floor_plank-diffuse.webp b/apps/editor/public/material/wood/floor_plank_1/floor_plank-diffuse.webp new file mode 100644 index 000000000..31720005d Binary files /dev/null and b/apps/editor/public/material/wood/floor_plank_1/floor_plank-diffuse.webp differ diff --git a/apps/editor/public/material/wood/floor_plank_1/floor_plank-displacement.webp b/apps/editor/public/material/wood/floor_plank_1/floor_plank-displacement.webp new file mode 100644 index 000000000..81ef4e4cb Binary files /dev/null and b/apps/editor/public/material/wood/floor_plank_1/floor_plank-displacement.webp differ diff --git a/apps/editor/public/material/wood/floor_plank_1/floor_plank-normal.webp b/apps/editor/public/material/wood/floor_plank_1/floor_plank-normal.webp new file mode 100644 index 000000000..2aed8031c Binary files /dev/null and b/apps/editor/public/material/wood/floor_plank_1/floor_plank-normal.webp differ diff --git a/apps/editor/public/material/wood/floor_plank_1/floor_plank-specular.webp b/apps/editor/public/material/wood/floor_plank_1/floor_plank-specular.webp new file mode 100644 index 000000000..77ddd629c Binary files /dev/null and b/apps/editor/public/material/wood/floor_plank_1/floor_plank-specular.webp differ diff --git a/apps/editor/public/material/wood/hungarian_parquet_10/Hungarian Parquet_10_baseColor.webp b/apps/editor/public/material/wood/hungarian_parquet_10/Hungarian Parquet_10_baseColor.webp new file mode 100644 index 000000000..deada3e6b Binary files /dev/null and b/apps/editor/public/material/wood/hungarian_parquet_10/Hungarian Parquet_10_baseColor.webp differ diff --git a/apps/editor/public/material/wood/hungarian_parquet_10/Hungarian Parquet_10_normal.webp b/apps/editor/public/material/wood/hungarian_parquet_10/Hungarian Parquet_10_normal.webp new file mode 100644 index 000000000..b1a4c1b19 Binary files /dev/null and b/apps/editor/public/material/wood/hungarian_parquet_10/Hungarian Parquet_10_normal.webp differ diff --git a/apps/editor/public/material/wood/hungarian_parquet_10/Hungarian Parquet_10_roughness.webp b/apps/editor/public/material/wood/hungarian_parquet_10/Hungarian Parquet_10_roughness.webp new file mode 100644 index 000000000..fe93f3cd3 Binary files /dev/null and b/apps/editor/public/material/wood/hungarian_parquet_10/Hungarian Parquet_10_roughness.webp differ diff --git a/apps/editor/public/material/wood/hungarian_parquet_10/Hungarian Parquet_10_specularLevel.webp b/apps/editor/public/material/wood/hungarian_parquet_10/Hungarian Parquet_10_specularLevel.webp new file mode 100644 index 000000000..830bc54a4 Binary files /dev/null and b/apps/editor/public/material/wood/hungarian_parquet_10/Hungarian Parquet_10_specularLevel.webp differ diff --git a/apps/editor/public/material/wood/hungarian_parquet_2/Hungarian Parquet_2_baseColor.webp b/apps/editor/public/material/wood/hungarian_parquet_2/Hungarian Parquet_2_baseColor.webp new file mode 100644 index 000000000..3d655fd86 Binary files /dev/null and b/apps/editor/public/material/wood/hungarian_parquet_2/Hungarian Parquet_2_baseColor.webp differ diff --git a/apps/editor/public/material/wood/hungarian_parquet_2/Hungarian Parquet_2_normal.webp b/apps/editor/public/material/wood/hungarian_parquet_2/Hungarian Parquet_2_normal.webp new file mode 100644 index 000000000..d735d3c6c Binary files /dev/null and b/apps/editor/public/material/wood/hungarian_parquet_2/Hungarian Parquet_2_normal.webp differ diff --git a/apps/editor/public/material/wood/hungarian_parquet_2/Hungarian Parquet_2_roughness.webp b/apps/editor/public/material/wood/hungarian_parquet_2/Hungarian Parquet_2_roughness.webp new file mode 100644 index 000000000..fe93f3cd3 Binary files /dev/null and b/apps/editor/public/material/wood/hungarian_parquet_2/Hungarian Parquet_2_roughness.webp differ diff --git a/apps/editor/public/material/wood/hungarian_parquet_2/Hungarian Parquet_2_specularLevel.webp b/apps/editor/public/material/wood/hungarian_parquet_2/Hungarian Parquet_2_specularLevel.webp new file mode 100644 index 000000000..830bc54a4 Binary files /dev/null and b/apps/editor/public/material/wood/hungarian_parquet_2/Hungarian Parquet_2_specularLevel.webp differ diff --git a/apps/editor/public/material/wood/square_parquet_21/Square Pattern Parquet_21_baseColor.webp b/apps/editor/public/material/wood/square_parquet_21/Square Pattern Parquet_21_baseColor.webp new file mode 100644 index 000000000..ce77188c1 Binary files /dev/null and b/apps/editor/public/material/wood/square_parquet_21/Square Pattern Parquet_21_baseColor.webp differ diff --git a/apps/editor/public/material/wood/square_parquet_21/Square Pattern Parquet_21_normal.webp b/apps/editor/public/material/wood/square_parquet_21/Square Pattern Parquet_21_normal.webp new file mode 100644 index 000000000..2383f22c7 Binary files /dev/null and b/apps/editor/public/material/wood/square_parquet_21/Square Pattern Parquet_21_normal.webp differ diff --git a/apps/editor/public/material/wood/square_parquet_21/Square Pattern Parquet_21_roughness.webp b/apps/editor/public/material/wood/square_parquet_21/Square Pattern Parquet_21_roughness.webp new file mode 100644 index 000000000..fe93f3cd3 Binary files /dev/null and b/apps/editor/public/material/wood/square_parquet_21/Square Pattern Parquet_21_roughness.webp differ diff --git a/apps/editor/public/material/wood/square_parquet_21/Square Pattern Parquet_21_specularLevel.webp b/apps/editor/public/material/wood/square_parquet_21/Square Pattern Parquet_21_specularLevel.webp new file mode 100644 index 000000000..830bc54a4 Binary files /dev/null and b/apps/editor/public/material/wood/square_parquet_21/Square Pattern Parquet_21_specularLevel.webp differ diff --git a/apps/editor/public/material/wood/square_wood_parquet_23/Square Pattern Parquet_23_baseColor.webp b/apps/editor/public/material/wood/square_wood_parquet_23/Square Pattern Parquet_23_baseColor.webp new file mode 100644 index 000000000..89630b1bc Binary files /dev/null and b/apps/editor/public/material/wood/square_wood_parquet_23/Square Pattern Parquet_23_baseColor.webp differ diff --git a/apps/editor/public/material/wood/square_wood_parquet_23/Square Pattern Parquet_23_normal.webp b/apps/editor/public/material/wood/square_wood_parquet_23/Square Pattern Parquet_23_normal.webp new file mode 100644 index 000000000..73fbe70e8 Binary files /dev/null and b/apps/editor/public/material/wood/square_wood_parquet_23/Square Pattern Parquet_23_normal.webp differ diff --git a/apps/editor/public/material/wood/square_wood_parquet_23/Square Pattern Parquet_23_roughness.webp b/apps/editor/public/material/wood/square_wood_parquet_23/Square Pattern Parquet_23_roughness.webp new file mode 100644 index 000000000..fe93f3cd3 Binary files /dev/null and b/apps/editor/public/material/wood/square_wood_parquet_23/Square Pattern Parquet_23_roughness.webp differ diff --git a/apps/editor/public/material/wood/square_wood_parquet_23/Square Pattern Parquet_23_specularLevel.webp b/apps/editor/public/material/wood/square_wood_parquet_23/Square Pattern Parquet_23_specularLevel.webp new file mode 100644 index 000000000..830bc54a4 Binary files /dev/null and b/apps/editor/public/material/wood/square_wood_parquet_23/Square Pattern Parquet_23_specularLevel.webp differ diff --git a/apps/editor/public/material/wood/wood_fine/wood_fine_1-ao.webp b/apps/editor/public/material/wood/wood_fine/wood_fine_1-ao.webp new file mode 100644 index 000000000..6ff2655fa Binary files /dev/null and b/apps/editor/public/material/wood/wood_fine/wood_fine_1-ao.webp differ diff --git a/apps/editor/public/material/wood/wood_fine/wood_fine_1-diffuse.webp b/apps/editor/public/material/wood/wood_fine/wood_fine_1-diffuse.webp new file mode 100644 index 000000000..292e0746d Binary files /dev/null and b/apps/editor/public/material/wood/wood_fine/wood_fine_1-diffuse.webp differ diff --git a/apps/editor/public/material/wood/wood_fine/wood_fine_1-displacement.webp b/apps/editor/public/material/wood/wood_fine/wood_fine_1-displacement.webp new file mode 100644 index 000000000..70222ead9 Binary files /dev/null and b/apps/editor/public/material/wood/wood_fine/wood_fine_1-displacement.webp differ diff --git a/apps/editor/public/material/wood/wood_fine/wood_fine_1-normal.webp b/apps/editor/public/material/wood/wood_fine/wood_fine_1-normal.webp new file mode 100644 index 000000000..94cb61050 Binary files /dev/null and b/apps/editor/public/material/wood/wood_fine/wood_fine_1-normal.webp differ diff --git a/apps/editor/public/material/wood/wood_fine/wood_fine_1-specular.webp b/apps/editor/public/material/wood/wood_fine/wood_fine_1-specular.webp new file mode 100644 index 000000000..4e4cfaba5 Binary files /dev/null and b/apps/editor/public/material/wood/wood_fine/wood_fine_1-specular.webp differ diff --git a/apps/editor/public/material/wood/wood_fine_11/wood_fine_11-ao.webp b/apps/editor/public/material/wood/wood_fine_11/wood_fine_11-ao.webp new file mode 100644 index 000000000..2f6014fde Binary files /dev/null and b/apps/editor/public/material/wood/wood_fine_11/wood_fine_11-ao.webp differ diff --git a/apps/editor/public/material/wood/wood_fine_11/wood_fine_11-diffuse.webp b/apps/editor/public/material/wood/wood_fine_11/wood_fine_11-diffuse.webp new file mode 100644 index 000000000..781382d6a Binary files /dev/null and b/apps/editor/public/material/wood/wood_fine_11/wood_fine_11-diffuse.webp differ diff --git a/apps/editor/public/material/wood/wood_fine_11/wood_fine_11-displacement.webp b/apps/editor/public/material/wood/wood_fine_11/wood_fine_11-displacement.webp new file mode 100644 index 000000000..310ed25be Binary files /dev/null and b/apps/editor/public/material/wood/wood_fine_11/wood_fine_11-displacement.webp differ diff --git a/apps/editor/public/material/wood/wood_fine_11/wood_fine_11-normal.webp b/apps/editor/public/material/wood/wood_fine_11/wood_fine_11-normal.webp new file mode 100644 index 000000000..be42af78e Binary files /dev/null and b/apps/editor/public/material/wood/wood_fine_11/wood_fine_11-normal.webp differ diff --git a/apps/editor/public/material/wood/wood_fine_11/wood_fine_11-specular.webp b/apps/editor/public/material/wood/wood_fine_11/wood_fine_11-specular.webp new file mode 100644 index 000000000..3fa0ec416 Binary files /dev/null and b/apps/editor/public/material/wood/wood_fine_11/wood_fine_11-specular.webp differ diff --git a/apps/editor/public/material/wood/wood_fine_13/wood_fine_13-ao.webp b/apps/editor/public/material/wood/wood_fine_13/wood_fine_13-ao.webp new file mode 100644 index 000000000..033ba8045 Binary files /dev/null and b/apps/editor/public/material/wood/wood_fine_13/wood_fine_13-ao.webp differ diff --git a/apps/editor/public/material/wood/wood_fine_13/wood_fine_13-diffuse.webp b/apps/editor/public/material/wood/wood_fine_13/wood_fine_13-diffuse.webp new file mode 100644 index 000000000..325a00780 Binary files /dev/null and b/apps/editor/public/material/wood/wood_fine_13/wood_fine_13-diffuse.webp differ diff --git a/apps/editor/public/material/wood/wood_fine_13/wood_fine_13-displacement.webp b/apps/editor/public/material/wood/wood_fine_13/wood_fine_13-displacement.webp new file mode 100644 index 000000000..6c3eb2bd5 Binary files /dev/null and b/apps/editor/public/material/wood/wood_fine_13/wood_fine_13-displacement.webp differ diff --git a/apps/editor/public/material/wood/wood_fine_13/wood_fine_13-normal.webp b/apps/editor/public/material/wood/wood_fine_13/wood_fine_13-normal.webp new file mode 100644 index 000000000..d7dda2793 Binary files /dev/null and b/apps/editor/public/material/wood/wood_fine_13/wood_fine_13-normal.webp differ diff --git a/apps/editor/public/material/wood/wood_fine_13/wood_fine_13-specular.webp b/apps/editor/public/material/wood/wood_fine_13/wood_fine_13-specular.webp new file mode 100644 index 000000000..f75e4e88c Binary files /dev/null and b/apps/editor/public/material/wood/wood_fine_13/wood_fine_13-specular.webp differ diff --git a/apps/editor/public/material/wood/wood_fine_2/wood_fine_2-ao.webp b/apps/editor/public/material/wood/wood_fine_2/wood_fine_2-ao.webp new file mode 100644 index 000000000..615fcb2c1 Binary files /dev/null and b/apps/editor/public/material/wood/wood_fine_2/wood_fine_2-ao.webp differ diff --git a/apps/editor/public/material/wood/wood_fine_2/wood_fine_2-diffuse.webp b/apps/editor/public/material/wood/wood_fine_2/wood_fine_2-diffuse.webp new file mode 100644 index 000000000..6e713ff93 Binary files /dev/null and b/apps/editor/public/material/wood/wood_fine_2/wood_fine_2-diffuse.webp differ diff --git a/apps/editor/public/material/wood/wood_fine_2/wood_fine_2-displacement.webp b/apps/editor/public/material/wood/wood_fine_2/wood_fine_2-displacement.webp new file mode 100644 index 000000000..fa2cead69 Binary files /dev/null and b/apps/editor/public/material/wood/wood_fine_2/wood_fine_2-displacement.webp differ diff --git a/apps/editor/public/material/wood/wood_fine_2/wood_fine_2-normal.webp b/apps/editor/public/material/wood/wood_fine_2/wood_fine_2-normal.webp new file mode 100644 index 000000000..339e556eb Binary files /dev/null and b/apps/editor/public/material/wood/wood_fine_2/wood_fine_2-normal.webp differ diff --git a/apps/editor/public/material/wood/wood_fine_2/wood_fine_2-specular.webp b/apps/editor/public/material/wood/wood_fine_2/wood_fine_2-specular.webp new file mode 100644 index 000000000..24f641034 Binary files /dev/null and b/apps/editor/public/material/wood/wood_fine_2/wood_fine_2-specular.webp differ diff --git a/apps/editor/public/material/wood/wood_fine_22/wood_fine_22-ao.webp b/apps/editor/public/material/wood/wood_fine_22/wood_fine_22-ao.webp new file mode 100644 index 000000000..2537a4dad Binary files /dev/null and b/apps/editor/public/material/wood/wood_fine_22/wood_fine_22-ao.webp differ diff --git a/apps/editor/public/material/wood/wood_fine_22/wood_fine_22-diffuse.webp b/apps/editor/public/material/wood/wood_fine_22/wood_fine_22-diffuse.webp new file mode 100644 index 000000000..e4291f1a4 Binary files /dev/null and b/apps/editor/public/material/wood/wood_fine_22/wood_fine_22-diffuse.webp differ diff --git a/apps/editor/public/material/wood/wood_fine_22/wood_fine_22-displacement.webp b/apps/editor/public/material/wood/wood_fine_22/wood_fine_22-displacement.webp new file mode 100644 index 000000000..a5b4c329c Binary files /dev/null and b/apps/editor/public/material/wood/wood_fine_22/wood_fine_22-displacement.webp differ diff --git a/apps/editor/public/material/wood/wood_fine_22/wood_fine_22-normal.webp b/apps/editor/public/material/wood/wood_fine_22/wood_fine_22-normal.webp new file mode 100644 index 000000000..8f7a54490 Binary files /dev/null and b/apps/editor/public/material/wood/wood_fine_22/wood_fine_22-normal.webp differ diff --git a/apps/editor/public/material/wood/wood_fine_22/wood_fine_22-specular.webp b/apps/editor/public/material/wood/wood_fine_22/wood_fine_22-specular.webp new file mode 100644 index 000000000..eff8bbcfc Binary files /dev/null and b/apps/editor/public/material/wood/wood_fine_22/wood_fine_22-specular.webp differ diff --git a/apps/editor/public/material/wood/wood_fine_24/wood_fine_24-ao.webp b/apps/editor/public/material/wood/wood_fine_24/wood_fine_24-ao.webp new file mode 100644 index 000000000..770aa08be Binary files /dev/null and b/apps/editor/public/material/wood/wood_fine_24/wood_fine_24-ao.webp differ diff --git a/apps/editor/public/material/wood/wood_fine_24/wood_fine_24-diffuse.webp b/apps/editor/public/material/wood/wood_fine_24/wood_fine_24-diffuse.webp new file mode 100644 index 000000000..e1fe4747e Binary files /dev/null and b/apps/editor/public/material/wood/wood_fine_24/wood_fine_24-diffuse.webp differ diff --git a/apps/editor/public/material/wood/wood_fine_24/wood_fine_24-displacement.webp b/apps/editor/public/material/wood/wood_fine_24/wood_fine_24-displacement.webp new file mode 100644 index 000000000..80d03819b Binary files /dev/null and b/apps/editor/public/material/wood/wood_fine_24/wood_fine_24-displacement.webp differ diff --git a/apps/editor/public/material/wood/wood_fine_24/wood_fine_24-normal.webp b/apps/editor/public/material/wood/wood_fine_24/wood_fine_24-normal.webp new file mode 100644 index 000000000..a79c91bd5 Binary files /dev/null and b/apps/editor/public/material/wood/wood_fine_24/wood_fine_24-normal.webp differ diff --git a/apps/editor/public/material/wood/wood_fine_24/wood_fine_24-specular.webp b/apps/editor/public/material/wood/wood_fine_24/wood_fine_24-specular.webp new file mode 100644 index 000000000..5579a98c8 Binary files /dev/null and b/apps/editor/public/material/wood/wood_fine_24/wood_fine_24-specular.webp differ diff --git a/apps/editor/public/material/wood/wood_parquet_14/woodparquet_14_ambientocclusion.webp b/apps/editor/public/material/wood/wood_parquet_14/woodparquet_14_ambientocclusion.webp new file mode 100644 index 000000000..9ebf7f5f5 Binary files /dev/null and b/apps/editor/public/material/wood/wood_parquet_14/woodparquet_14_ambientocclusion.webp differ diff --git a/apps/editor/public/material/wood/wood_parquet_14/woodparquet_14_basecolor.webp b/apps/editor/public/material/wood/wood_parquet_14/woodparquet_14_basecolor.webp new file mode 100644 index 000000000..985e098bc Binary files /dev/null and b/apps/editor/public/material/wood/wood_parquet_14/woodparquet_14_basecolor.webp differ diff --git a/apps/editor/public/material/wood/wood_parquet_14/woodparquet_14_height.webp b/apps/editor/public/material/wood/wood_parquet_14/woodparquet_14_height.webp new file mode 100644 index 000000000..5f5307ad8 Binary files /dev/null and b/apps/editor/public/material/wood/wood_parquet_14/woodparquet_14_height.webp differ diff --git a/apps/editor/public/material/wood/wood_parquet_14/woodparquet_14_metallic.webp b/apps/editor/public/material/wood/wood_parquet_14/woodparquet_14_metallic.webp new file mode 100644 index 000000000..269e23a78 Binary files /dev/null and b/apps/editor/public/material/wood/wood_parquet_14/woodparquet_14_metallic.webp differ diff --git a/apps/editor/public/material/wood/wood_parquet_14/woodparquet_14_normal.webp b/apps/editor/public/material/wood/wood_parquet_14/woodparquet_14_normal.webp new file mode 100644 index 000000000..d8b9c02e7 Binary files /dev/null and b/apps/editor/public/material/wood/wood_parquet_14/woodparquet_14_normal.webp differ diff --git a/apps/editor/public/material/wood/wood_parquet_14/woodparquet_14_roughness.webp b/apps/editor/public/material/wood/wood_parquet_14/woodparquet_14_roughness.webp new file mode 100644 index 000000000..f9b52bac0 Binary files /dev/null and b/apps/editor/public/material/wood/wood_parquet_14/woodparquet_14_roughness.webp differ diff --git a/apps/editor/public/material/wood/wooden_parquet_11/Classic Parquet_11_baseColor.webp b/apps/editor/public/material/wood/wooden_parquet_11/Classic Parquet_11_baseColor.webp new file mode 100644 index 000000000..c3cf04109 Binary files /dev/null and b/apps/editor/public/material/wood/wooden_parquet_11/Classic Parquet_11_baseColor.webp differ diff --git a/apps/editor/public/material/wood/wooden_parquet_11/Classic Parquet_11_normal.webp b/apps/editor/public/material/wood/wooden_parquet_11/Classic Parquet_11_normal.webp new file mode 100644 index 000000000..78fe2ee2b Binary files /dev/null and b/apps/editor/public/material/wood/wooden_parquet_11/Classic Parquet_11_normal.webp differ diff --git a/apps/editor/public/material/wood/wooden_parquet_11/Classic Parquet_11_roughness.webp b/apps/editor/public/material/wood/wooden_parquet_11/Classic Parquet_11_roughness.webp new file mode 100644 index 000000000..86077fd5c Binary files /dev/null and b/apps/editor/public/material/wood/wooden_parquet_11/Classic Parquet_11_roughness.webp differ diff --git a/apps/editor/public/material/wood/wooden_parquet_11/Classic Parquet_11_specularLevel.webp b/apps/editor/public/material/wood/wooden_parquet_11/Classic Parquet_11_specularLevel.webp new file mode 100644 index 000000000..830bc54a4 Binary files /dev/null and b/apps/editor/public/material/wood/wooden_parquet_11/Classic Parquet_11_specularLevel.webp differ diff --git a/apps/editor/public/material/wood/woodparquet_121/woodparquet_121_ambientocclusion.webp b/apps/editor/public/material/wood/woodparquet_121/woodparquet_121_ambientocclusion.webp new file mode 100644 index 000000000..c349e2688 Binary files /dev/null and b/apps/editor/public/material/wood/woodparquet_121/woodparquet_121_ambientocclusion.webp differ diff --git a/apps/editor/public/material/wood/woodparquet_121/woodparquet_121_basecolor.webp b/apps/editor/public/material/wood/woodparquet_121/woodparquet_121_basecolor.webp new file mode 100644 index 000000000..d58b95cfd Binary files /dev/null and b/apps/editor/public/material/wood/woodparquet_121/woodparquet_121_basecolor.webp differ diff --git a/apps/editor/public/material/wood/woodparquet_121/woodparquet_121_height.webp b/apps/editor/public/material/wood/woodparquet_121/woodparquet_121_height.webp new file mode 100644 index 000000000..dcffb4c83 Binary files /dev/null and b/apps/editor/public/material/wood/woodparquet_121/woodparquet_121_height.webp differ diff --git a/apps/editor/public/material/wood/woodparquet_121/woodparquet_121_joint.webp b/apps/editor/public/material/wood/woodparquet_121/woodparquet_121_joint.webp new file mode 100644 index 000000000..5637053d2 Binary files /dev/null and b/apps/editor/public/material/wood/woodparquet_121/woodparquet_121_joint.webp differ diff --git a/apps/editor/public/material/wood/woodparquet_121/woodparquet_121_normal.webp b/apps/editor/public/material/wood/woodparquet_121/woodparquet_121_normal.webp new file mode 100644 index 000000000..f259108fe Binary files /dev/null and b/apps/editor/public/material/wood/woodparquet_121/woodparquet_121_normal.webp differ diff --git a/apps/editor/public/material/wood/woodparquet_121/woodparquet_121_roughness.webp b/apps/editor/public/material/wood/woodparquet_121/woodparquet_121_roughness.webp new file mode 100644 index 000000000..4e9a0bb13 Binary files /dev/null and b/apps/editor/public/material/wood/woodparquet_121/woodparquet_121_roughness.webp differ diff --git a/apps/editor/public/material/wood/woodparquet_56/woodparquet_56_ambientocclusion.webp b/apps/editor/public/material/wood/woodparquet_56/woodparquet_56_ambientocclusion.webp new file mode 100644 index 000000000..895ae2c00 Binary files /dev/null and b/apps/editor/public/material/wood/woodparquet_56/woodparquet_56_ambientocclusion.webp differ diff --git a/apps/editor/public/material/wood/woodparquet_56/woodparquet_56_basecolor.webp b/apps/editor/public/material/wood/woodparquet_56/woodparquet_56_basecolor.webp new file mode 100644 index 000000000..c3141f808 Binary files /dev/null and b/apps/editor/public/material/wood/woodparquet_56/woodparquet_56_basecolor.webp differ diff --git a/apps/editor/public/material/wood/woodparquet_56/woodparquet_56_height.webp b/apps/editor/public/material/wood/woodparquet_56/woodparquet_56_height.webp new file mode 100644 index 000000000..39cfe81a2 Binary files /dev/null and b/apps/editor/public/material/wood/woodparquet_56/woodparquet_56_height.webp differ diff --git a/apps/editor/public/material/wood/woodparquet_56/woodparquet_56_metallic.webp b/apps/editor/public/material/wood/woodparquet_56/woodparquet_56_metallic.webp new file mode 100644 index 000000000..269e23a78 Binary files /dev/null and b/apps/editor/public/material/wood/woodparquet_56/woodparquet_56_metallic.webp differ diff --git a/apps/editor/public/material/wood/woodparquet_56/woodparquet_56_normal.webp b/apps/editor/public/material/wood/woodparquet_56/woodparquet_56_normal.webp new file mode 100644 index 000000000..1e1a42b14 Binary files /dev/null and b/apps/editor/public/material/wood/woodparquet_56/woodparquet_56_normal.webp differ diff --git a/apps/editor/public/material/wood/woodparquet_56/woodparquet_56_roughness.webp b/apps/editor/public/material/wood/woodparquet_56/woodparquet_56_roughness.webp new file mode 100644 index 000000000..6622ce0ab Binary files /dev/null and b/apps/editor/public/material/wood/woodparquet_56/woodparquet_56_roughness.webp differ diff --git a/apps/editor/public/material/wood/woodparquet_65/woodparquet_65_AmbientOcclusion.webp b/apps/editor/public/material/wood/woodparquet_65/woodparquet_65_AmbientOcclusion.webp new file mode 100644 index 000000000..ac337bec6 Binary files /dev/null and b/apps/editor/public/material/wood/woodparquet_65/woodparquet_65_AmbientOcclusion.webp differ diff --git a/apps/editor/public/material/wood/woodparquet_65/woodparquet_65_BaseColor.webp b/apps/editor/public/material/wood/woodparquet_65/woodparquet_65_BaseColor.webp new file mode 100644 index 000000000..abd1cb54e Binary files /dev/null and b/apps/editor/public/material/wood/woodparquet_65/woodparquet_65_BaseColor.webp differ diff --git a/apps/editor/public/material/wood/woodparquet_65/woodparquet_65_Height.webp b/apps/editor/public/material/wood/woodparquet_65/woodparquet_65_Height.webp new file mode 100644 index 000000000..5d608e7c2 Binary files /dev/null and b/apps/editor/public/material/wood/woodparquet_65/woodparquet_65_Height.webp differ diff --git a/apps/editor/public/material/wood/woodparquet_65/woodparquet_65_Metallic.webp b/apps/editor/public/material/wood/woodparquet_65/woodparquet_65_Metallic.webp new file mode 100644 index 000000000..269e23a78 Binary files /dev/null and b/apps/editor/public/material/wood/woodparquet_65/woodparquet_65_Metallic.webp differ diff --git a/apps/editor/public/material/wood/woodparquet_65/woodparquet_65_Normal.webp b/apps/editor/public/material/wood/woodparquet_65/woodparquet_65_Normal.webp new file mode 100644 index 000000000..cf63593e0 Binary files /dev/null and b/apps/editor/public/material/wood/woodparquet_65/woodparquet_65_Normal.webp differ diff --git a/apps/editor/public/material/wood/woodparquet_65/woodparquet_65_Roughness.webp b/apps/editor/public/material/wood/woodparquet_65/woodparquet_65_Roughness.webp new file mode 100644 index 000000000..707444499 Binary files /dev/null and b/apps/editor/public/material/wood/woodparquet_65/woodparquet_65_Roughness.webp differ diff --git a/apps/editor/public/material/wood/woodparquet_99/woodparquet_99_ambientocclusion.webp b/apps/editor/public/material/wood/woodparquet_99/woodparquet_99_ambientocclusion.webp new file mode 100644 index 000000000..a12e354db Binary files /dev/null and b/apps/editor/public/material/wood/woodparquet_99/woodparquet_99_ambientocclusion.webp differ diff --git a/apps/editor/public/material/wood/woodparquet_99/woodparquet_99_basecolor.webp b/apps/editor/public/material/wood/woodparquet_99/woodparquet_99_basecolor.webp new file mode 100644 index 000000000..8f0589555 Binary files /dev/null and b/apps/editor/public/material/wood/woodparquet_99/woodparquet_99_basecolor.webp differ diff --git a/apps/editor/public/material/wood/woodparquet_99/woodparquet_99_height.webp b/apps/editor/public/material/wood/woodparquet_99/woodparquet_99_height.webp new file mode 100644 index 000000000..4470f3079 Binary files /dev/null and b/apps/editor/public/material/wood/woodparquet_99/woodparquet_99_height.webp differ diff --git a/apps/editor/public/material/wood/woodparquet_99/woodparquet_99_metallic.webp b/apps/editor/public/material/wood/woodparquet_99/woodparquet_99_metallic.webp new file mode 100644 index 000000000..269e23a78 Binary files /dev/null and b/apps/editor/public/material/wood/woodparquet_99/woodparquet_99_metallic.webp differ diff --git a/apps/editor/public/material/wood/woodparquet_99/woodparquet_99_normal.webp b/apps/editor/public/material/wood/woodparquet_99/woodparquet_99_normal.webp new file mode 100644 index 000000000..fc9423671 Binary files /dev/null and b/apps/editor/public/material/wood/woodparquet_99/woodparquet_99_normal.webp differ diff --git a/apps/editor/public/material/wood/woodparquet_99/woodparquet_99_roughness.webp b/apps/editor/public/material/wood/woodparquet_99/woodparquet_99_roughness.webp new file mode 100644 index 000000000..e76089145 Binary files /dev/null and b/apps/editor/public/material/wood/woodparquet_99/woodparquet_99_roughness.webp differ diff --git a/apps/editor/public/material/wood/woodplank_19/woodplank_19_ambientocclusion.webp b/apps/editor/public/material/wood/woodplank_19/woodplank_19_ambientocclusion.webp new file mode 100644 index 000000000..a889ccc2b Binary files /dev/null and b/apps/editor/public/material/wood/woodplank_19/woodplank_19_ambientocclusion.webp differ diff --git a/apps/editor/public/material/wood/woodplank_19/woodplank_19_basecolor.webp b/apps/editor/public/material/wood/woodplank_19/woodplank_19_basecolor.webp new file mode 100644 index 000000000..d1c71fca5 Binary files /dev/null and b/apps/editor/public/material/wood/woodplank_19/woodplank_19_basecolor.webp differ diff --git a/apps/editor/public/material/wood/woodplank_19/woodplank_19_height.webp b/apps/editor/public/material/wood/woodplank_19/woodplank_19_height.webp new file mode 100644 index 000000000..efcb6e719 Binary files /dev/null and b/apps/editor/public/material/wood/woodplank_19/woodplank_19_height.webp differ diff --git a/apps/editor/public/material/wood/woodplank_19/woodplank_19_normal.webp b/apps/editor/public/material/wood/woodplank_19/woodplank_19_normal.webp new file mode 100644 index 000000000..5f9f42b23 Binary files /dev/null and b/apps/editor/public/material/wood/woodplank_19/woodplank_19_normal.webp differ diff --git a/apps/editor/public/material/wood/woodplank_19/woodplank_19_roughness.webp b/apps/editor/public/material/wood/woodplank_19/woodplank_19_roughness.webp new file mode 100644 index 000000000..ac4823e15 Binary files /dev/null and b/apps/editor/public/material/wood/woodplank_19/woodplank_19_roughness.webp differ diff --git a/apps/editor/public/material/wood/woodplank_48/woodplank_48_AmbientOcclusion.webp b/apps/editor/public/material/wood/woodplank_48/woodplank_48_AmbientOcclusion.webp new file mode 100644 index 000000000..ea59f66b9 Binary files /dev/null and b/apps/editor/public/material/wood/woodplank_48/woodplank_48_AmbientOcclusion.webp differ diff --git a/apps/editor/public/material/wood/woodplank_48/woodplank_48_BaseColor.webp b/apps/editor/public/material/wood/woodplank_48/woodplank_48_BaseColor.webp new file mode 100644 index 000000000..a2684f7ba Binary files /dev/null and b/apps/editor/public/material/wood/woodplank_48/woodplank_48_BaseColor.webp differ diff --git a/apps/editor/public/material/wood/woodplank_48/woodplank_48_Height.webp b/apps/editor/public/material/wood/woodplank_48/woodplank_48_Height.webp new file mode 100644 index 000000000..da1532d5f Binary files /dev/null and b/apps/editor/public/material/wood/woodplank_48/woodplank_48_Height.webp differ diff --git a/apps/editor/public/material/wood/woodplank_48/woodplank_48_Normal.webp b/apps/editor/public/material/wood/woodplank_48/woodplank_48_Normal.webp new file mode 100644 index 000000000..a387618dc Binary files /dev/null and b/apps/editor/public/material/wood/woodplank_48/woodplank_48_Normal.webp differ diff --git a/apps/editor/public/material/wood/woodplank_48/woodplank_48_Roughness.webp b/apps/editor/public/material/wood/woodplank_48/woodplank_48_Roughness.webp new file mode 100644 index 000000000..de3b785d9 Binary files /dev/null and b/apps/editor/public/material/wood/woodplank_48/woodplank_48_Roughness.webp differ diff --git a/apps/editor/public/material/wood1/albedoMap_basecolor.jpg b/apps/editor/public/material/wood1/albedoMap_basecolor.jpg deleted file mode 100644 index 5535070d0..000000000 Binary files a/apps/editor/public/material/wood1/albedoMap_basecolor.jpg and /dev/null differ diff --git a/apps/editor/public/material/wood1/normalMap_normal.jpg b/apps/editor/public/material/wood1/normalMap_normal.jpg deleted file mode 100644 index 7b741af5b..000000000 Binary files a/apps/editor/public/material/wood1/normalMap_normal.jpg and /dev/null differ diff --git a/apps/editor/public/material/wood1/wood1_thumbnail.webp b/apps/editor/public/material/wood1/wood1_thumbnail.webp deleted file mode 100644 index b549a6889..000000000 Binary files a/apps/editor/public/material/wood1/wood1_thumbnail.webp and /dev/null differ diff --git a/apps/editor/public/material/wood2/albedoMap_Wood.jpg b/apps/editor/public/material/wood2/albedoMap_Wood.jpg deleted file mode 100644 index 95d1f4bd5..000000000 Binary files a/apps/editor/public/material/wood2/albedoMap_Wood.jpg and /dev/null differ diff --git a/apps/editor/public/material/wood2/aoMap_Wood.jpg b/apps/editor/public/material/wood2/aoMap_Wood.jpg deleted file mode 100644 index a98e41a62..000000000 Binary files a/apps/editor/public/material/wood2/aoMap_Wood.jpg and /dev/null differ diff --git a/apps/editor/public/material/wood2/normalMap_Wood.jpg b/apps/editor/public/material/wood2/normalMap_Wood.jpg deleted file mode 100644 index e58c17a59..000000000 Binary files a/apps/editor/public/material/wood2/normalMap_Wood.jpg and /dev/null differ diff --git a/apps/editor/public/material/wood2/wood2_thumbnail.webp b/apps/editor/public/material/wood2/wood2_thumbnail.webp deleted file mode 100644 index 9413d27d9..000000000 Binary files a/apps/editor/public/material/wood2/wood2_thumbnail.webp and /dev/null differ diff --git a/apps/editor/public/material/wood3/albedoMap_knotted-timber.jpg b/apps/editor/public/material/wood3/albedoMap_knotted-timber.jpg deleted file mode 100644 index ecd8fe53c..000000000 Binary files a/apps/editor/public/material/wood3/albedoMap_knotted-timber.jpg and /dev/null differ diff --git a/apps/editor/public/material/wood3/wood3_thumbnail.webp b/apps/editor/public/material/wood3/wood3_thumbnail.webp deleted file mode 100644 index 138591d05..000000000 Binary files a/apps/editor/public/material/wood3/wood3_thumbnail.webp and /dev/null differ diff --git a/apps/editor/public/material/wood4/albedoMap_oak-stretcher.jpg b/apps/editor/public/material/wood4/albedoMap_oak-stretcher.jpg deleted file mode 100644 index debaa5659..000000000 Binary files a/apps/editor/public/material/wood4/albedoMap_oak-stretcher.jpg and /dev/null differ diff --git a/apps/editor/public/material/wood4/wood4_thumbnail.webp b/apps/editor/public/material/wood4/wood4_thumbnail.webp deleted file mode 100644 index e1abebd65..000000000 Binary files a/apps/editor/public/material/wood4/wood4_thumbnail.webp and /dev/null differ diff --git a/apps/editor/public/material/wood5/albedoMap_3_base_color.webp b/apps/editor/public/material/wood5/albedoMap_3_base_color.webp deleted file mode 100644 index d27f30acc..000000000 Binary files a/apps/editor/public/material/wood5/albedoMap_3_base_color.webp and /dev/null differ diff --git a/apps/editor/public/material/wood5/aoMap_3_ao.jpg b/apps/editor/public/material/wood5/aoMap_3_ao.jpg deleted file mode 100644 index c41629a6d..000000000 Binary files a/apps/editor/public/material/wood5/aoMap_3_ao.jpg and /dev/null differ diff --git a/apps/editor/public/material/wood5/normalMap_3_normal.jpg b/apps/editor/public/material/wood5/normalMap_3_normal.jpg deleted file mode 100644 index 2b27b3461..000000000 Binary files a/apps/editor/public/material/wood5/normalMap_3_normal.jpg and /dev/null differ diff --git a/apps/editor/public/material/wood5/wood5_thumnail.webp b/apps/editor/public/material/wood5/wood5_thumnail.webp deleted file mode 100644 index 6c255e2d4..000000000 Binary files a/apps/editor/public/material/wood5/wood5_thumnail.webp and /dev/null differ diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 9d50139f4..887f6fc6c 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -44,10 +44,10 @@ export { } from './lib/door-operation' export { getRenderableSlabPolygon } from './lib/slab-polygon' export { + type AutoSlabSyncPlan, detectSpacesForLevel, initSpaceDetectionSync, planAutoSlabsForLevel, - type AutoSlabSyncPlan, type Space, wallTouchesOthers, } from './lib/space-detection' @@ -89,6 +89,7 @@ export { default as useLiveTransforms, type LiveTransform } from './store/use-li export { clearSceneHistory, default as useScene } from './store/use-scene' export { resolveElevatorDispatchTarget } from './systems/elevator/elevator-dispatch' export { + type ElevatorDoorSide, getElevatorCabCenterZ, getElevatorCabDepth, getElevatorCabWidth, @@ -101,7 +102,6 @@ export { getResolvedElevatorDoorPanelStyle, getResolvedElevatorDoorStyle, getResolvedElevatorShaftStyle, - type ElevatorDoorSide, } from './systems/elevator/elevator-geometry' export { syncAutoElevatorOpenings } from './systems/elevator/elevator-opening-sync' export { ElevatorOpeningSystem } from './systems/elevator/elevator-opening-system' @@ -125,6 +125,7 @@ export { resolveElevatorServiceLevels, } from './systems/elevator/elevator-service' export { syncAutoStairOpenings } from './systems/stair/stair-opening-sync' +export { StairOpeningSystem } from './systems/stair/stair-opening-system' export { getClampedWallCurveOffset, getMaxWallCurveOffset, @@ -157,8 +158,8 @@ export { constrainWallMoveDeltaToAxis, getPerpendicularWallMoveAxis, planWallMoveJunctions, - type WallMoveBridgePlan, type WallMoveAxis, + type WallMoveBridgePlan, type WallMoveJunctionPlan, type WallPlanPoint, } from './systems/wall/wall-move' diff --git a/packages/core/src/material-library.ts b/packages/core/src/material-library.ts index a3622cbe7..e0974830a 100644 --- a/packages/core/src/material-library.ts +++ b/packages/core/src/material-library.ts @@ -40,31 +40,26 @@ const ROOF_TARGETS: MaterialTarget[] = [ const CEILING_TARGETS: MaterialTarget[] = [MaterialTargetSchema.enum.ceiling] -export const MATERIAL_CATEGORIES = [ - 'wood', - 'wallpaper', - 'parquet', - 'granite', - 'marble', - 'other', -] as const +export const MATERIAL_CATEGORIES = ['wood', 'flooring', 'roof', 'other'] as const export type MaterialCategory = (typeof MATERIAL_CATEGORIES)[number] export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ { - id: 'wall-wood1', - label: 'Wood', + id: 'wood-finewood27', + label: 'Finewood 27', category: 'wood', - description: 'Warm wood finish', - previewThumbnailUrl: '/material/wood1/wood1_thumbnail.webp', + description: 'Fine wood finish', + previewThumbnailUrl: '/material/wood/finewood_27/finewood_27_basecolor.webp', preset: { maps: { - albedoMap: '/material/wood1/albedoMap_basecolor.jpg', - normalMap: '/material/wood1/normalMap_normal.jpg', + albedoMap: '/material/wood/finewood_27/finewood_27_basecolor.webp', + aoMap: '/material/wood/finewood_27/finewood_27_ambientocclusion.webp', + normalMap: '/material/wood/finewood_27/finewood_27_normal.webp', + roughnessMap: '/material/wood/finewood_27/finewood_27_roughness.webp', }, mapProperties: { color: '#ffffff', - roughness: 0.575, + roughness: 0.5, metalness: 0, repeatX: 1, repeatY: 1, @@ -74,8 +69,45 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ normalScaleX: 1, normalScaleY: 1, emissiveIntensity: 1, - displacementScale: 0.02, - transparent: true, + displacementScale: 0, + transparent: false, + flipY: true, + bumpScale: 1, + emissiveColor: '#000000', + aoMapIntensity: 1, + side: 0, + opacity: 1, + lightMapIntensity: 1, + }, + }, + }, + { + id: 'wood-floorplank1', + label: 'Floor Plank 1', + category: 'wood', + description: 'Wood plank finish', + previewThumbnailUrl: '/material/wood/floor_plank_1/floor_plank-diffuse.webp', + preset: { + maps: { + albedoMap: '/material/wood/floor_plank_1/floor_plank-diffuse.webp', + aoMap: '/material/wood/floor_plank_1/floor_plank-ao.webp', + metalnessMap: '/material/wood/floor_plank_1/floor_plank-specular.webp', + normalMap: '/material/wood/floor_plank_1/floor_plank-normal.webp', + }, + mapProperties: { + color: '#ffffff', + roughness: 0.45, + metalness: 0, + repeatX: 0.4, + repeatY: 0.4, + rotation: 0, + wrapS: 'Repeat', + wrapT: 'Repeat', + normalScaleX: 1, + normalScaleY: 1, + emissiveIntensity: 1, + displacementScale: 0, + transparent: false, flipY: true, bumpScale: 1, emissiveColor: '#000000', @@ -87,35 +119,36 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ }, }, { - id: 'wall-wood2', - label: 'Wood', + id: 'wood-hungarianparquet10', + label: 'Hungarian Parquet 10', category: 'wood', - description: 'Textured wood finish', - previewThumbnailUrl: '/material/wood2/wood2_thumbnail.webp', + description: 'Parquet wood finish', + previewThumbnailUrl: '/material/wood/hungarian_parquet_10/Hungarian Parquet_10_baseColor.webp', preset: { maps: { - albedoMap: '/material/wood2/albedoMap_Wood.jpg', - normalMap: '/material/wood2/normalMap_Wood.jpg', - aoMap: '/material/wood2/aoMap_Wood.jpg', + albedoMap: '/material/wood/hungarian_parquet_10/Hungarian Parquet_10_baseColor.webp', + metalnessMap: '/material/wood/hungarian_parquet_10/Hungarian Parquet_10_specularLevel.webp', + normalMap: '/material/wood/hungarian_parquet_10/Hungarian Parquet_10_normal.webp', + roughnessMap: '/material/wood/hungarian_parquet_10/Hungarian Parquet_10_roughness.webp', }, mapProperties: { color: '#ffffff', - roughness: 0.467, + roughness: 0.5, metalness: 0, repeatX: 1, repeatY: 1, rotation: 0, wrapS: 'Repeat', wrapT: 'Repeat', - normalScaleX: 2, - normalScaleY: 2, + normalScaleX: 1, + normalScaleY: 1, emissiveIntensity: 1, - displacementScale: 0.02, - transparent: true, + displacementScale: 0, + transparent: false, flipY: true, bumpScale: 1, emissiveColor: '#000000', - aoMapIntensity: 2, + aoMapIntensity: 1, side: 0, opacity: 1, lightMapIntensity: 1, @@ -123,33 +156,36 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ }, }, { - id: 'wall-wood3', - label: 'Wood', + id: 'wood-hungarianparquet2', + label: 'Hungarian Parquet 2', category: 'wood', - description: 'Knotted timber finish', - previewThumbnailUrl: '/material/wood3/wood3_thumbnail.webp', + description: 'Parquet wood finish', + previewThumbnailUrl: '/material/wood/hungarian_parquet_2/Hungarian Parquet_2_baseColor.webp', preset: { maps: { - albedoMap: '/material/wood3/albedoMap_knotted-timber.jpg', + albedoMap: '/material/wood/hungarian_parquet_2/Hungarian Parquet_2_baseColor.webp', + metalnessMap: '/material/wood/hungarian_parquet_2/Hungarian Parquet_2_specularLevel.webp', + normalMap: '/material/wood/hungarian_parquet_2/Hungarian Parquet_2_normal.webp', + roughnessMap: '/material/wood/hungarian_parquet_2/Hungarian Parquet_2_roughness.webp', }, mapProperties: { color: '#ffffff', - roughness: 0.489, + roughness: 0.5, metalness: 0, repeatX: 1, repeatY: 1, rotation: 0, wrapS: 'Repeat', wrapT: 'Repeat', - normalScaleX: 0.2, - normalScaleY: 0.2, + normalScaleX: 1, + normalScaleY: 1, emissiveIntensity: 1, - displacementScale: 0.02, - transparent: true, + displacementScale: 0, + transparent: false, flipY: true, bumpScale: 1, emissiveColor: '#000000', - aoMapIntensity: 2, + aoMapIntensity: 1, side: 0, opacity: 1, lightMapIntensity: 1, @@ -157,18 +193,23 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ }, }, { - id: 'wall-wood4', - label: 'Wood', + id: 'wood-squareparquet21', + label: 'Square Parquet 21', category: 'wood', - description: 'Oak stretcher finish', - previewThumbnailUrl: '/material/wood4/wood4_thumbnail.webp', + description: 'Parquet wood finish', + previewThumbnailUrl: + '/material/wood/square_parquet_21/Square Pattern Parquet_21_baseColor.webp', preset: { maps: { - albedoMap: '/material/wood4/albedoMap_oak-stretcher.jpg', + albedoMap: '/material/wood/square_parquet_21/Square Pattern Parquet_21_baseColor.webp', + metalnessMap: + '/material/wood/square_parquet_21/Square Pattern Parquet_21_specularLevel.webp', + normalMap: '/material/wood/square_parquet_21/Square Pattern Parquet_21_normal.webp', + roughnessMap: '/material/wood/square_parquet_21/Square Pattern Parquet_21_roughness.webp', }, mapProperties: { color: '#ffffff', - roughness: 0.378, + roughness: 0.5, metalness: 0, repeatX: 1, repeatY: 1, @@ -178,8 +219,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ normalScaleX: 1, normalScaleY: 1, emissiveIntensity: 1, - displacementScale: 0.02, - transparent: true, + displacementScale: 0, + transparent: false, flipY: true, bumpScale: 1, emissiveColor: '#000000', @@ -191,20 +232,24 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ }, }, { - id: 'wall-wood5', - label: 'Wood', + id: 'wood-squareparquet23', + label: 'Square Parquet 23', category: 'wood', - description: 'Rich grain wood finish', - previewThumbnailUrl: '/material/wood5/wood5_thumnail.webp', + description: 'Parquet wood finish', + previewThumbnailUrl: + '/material/wood/square_wood_parquet_23/Square Pattern Parquet_23_baseColor.webp', preset: { maps: { - albedoMap: '/material/wood5/albedoMap_3_base_color.webp', - normalMap: '/material/wood5/normalMap_3_normal.jpg', - aoMap: '/material/wood5/aoMap_3_ao.jpg', + albedoMap: '/material/wood/square_wood_parquet_23/Square Pattern Parquet_23_baseColor.webp', + metalnessMap: + '/material/wood/square_wood_parquet_23/Square Pattern Parquet_23_specularLevel.webp', + normalMap: '/material/wood/square_wood_parquet_23/Square Pattern Parquet_23_normal.webp', + roughnessMap: + '/material/wood/square_wood_parquet_23/Square Pattern Parquet_23_roughness.webp', }, mapProperties: { color: '#ffffff', - roughness: 0.6, + roughness: 0.5, metalness: 0, repeatX: 1, repeatY: 1, @@ -214,12 +259,12 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ normalScaleX: 1, normalScaleY: 1, emissiveIntensity: 1, - displacementScale: 0.02, - transparent: true, + displacementScale: 0, + transparent: false, flipY: true, bumpScale: 1, emissiveColor: '#000000', - aoMapIntensity: 10, + aoMapIntensity: 1, side: 0, opacity: 1, lightMapIntensity: 1, @@ -227,18 +272,21 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ }, }, { - id: 'wall-granite1', - label: 'Granite', - category: 'granite', - description: 'Polished granite finish', - previewThumbnailUrl: '/material/granite1/granite_thumbnail.webp', + id: 'wood-woodfine1', + label: 'Wood Fine 1', + category: 'wood', + description: 'Fine wood finish', + previewThumbnailUrl: '/material/wood/wood_fine/wood_fine_1-diffuse.webp', preset: { maps: { - albedoMap: '/material/granite1/albedoMap_Granite.jpg', + albedoMap: '/material/wood/wood_fine/wood_fine_1-diffuse.webp', + aoMap: '/material/wood/wood_fine/wood_fine_1-ao.webp', + metalnessMap: '/material/wood/wood_fine/wood_fine_1-specular.webp', + normalMap: '/material/wood/wood_fine/wood_fine_1-normal.webp', }, mapProperties: { color: '#ffffff', - roughness: 0.189, + roughness: 0.45, metalness: 0, repeatX: 1, repeatY: 1, @@ -248,8 +296,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ normalScaleX: 1, normalScaleY: 1, emissiveIntensity: 1, - displacementScale: 0.02, - transparent: true, + displacementScale: 0, + transparent: false, flipY: true, bumpScale: 1, emissiveColor: '#000000', @@ -261,18 +309,21 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ }, }, { - id: 'wall-marble1', - label: 'Marble', - category: 'marble', - description: 'Smooth marble finish', - previewThumbnailUrl: '/material/marble1/marble1_thumbnail.webp', + id: 'wood-woodfine11', + label: 'Wood Fine 11', + category: 'wood', + description: 'Fine wood finish', + previewThumbnailUrl: '/material/wood/wood_fine_11/wood_fine_11-diffuse.webp', preset: { maps: { - albedoMap: '/material/marble1/albedoMap_marble.jpg', + albedoMap: '/material/wood/wood_fine_11/wood_fine_11-diffuse.webp', + aoMap: '/material/wood/wood_fine_11/wood_fine_11-ao.webp', + metalnessMap: '/material/wood/wood_fine_11/wood_fine_11-specular.webp', + normalMap: '/material/wood/wood_fine_11/wood_fine_11-normal.webp', }, mapProperties: { color: '#ffffff', - roughness: 0.133, + roughness: 0.45, metalness: 0, repeatX: 1, repeatY: 1, @@ -282,8 +333,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ normalScaleX: 1, normalScaleY: 1, emissiveIntensity: 1, - displacementScale: 0.02, - transparent: true, + displacementScale: 0, + transparent: false, flipY: true, bumpScale: 1, emissiveColor: '#000000', @@ -295,18 +346,21 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ }, }, { - id: 'wall-marble2', - label: 'Marble', - category: 'marble', - description: 'Soft marble finish', - previewThumbnailUrl: '/material/marble2/marble2_thumbnail.webp', + id: 'wood-woodfine13', + label: 'Wood Fine 13', + category: 'wood', + description: 'Fine wood finish', + previewThumbnailUrl: '/material/wood/wood_fine_13/wood_fine_13-diffuse.webp', preset: { maps: { - albedoMap: '/material/marble2/albedoMap_marble.jpg', + albedoMap: '/material/wood/wood_fine_13/wood_fine_13-diffuse.webp', + aoMap: '/material/wood/wood_fine_13/wood_fine_13-ao.webp', + metalnessMap: '/material/wood/wood_fine_13/wood_fine_13-specular.webp', + normalMap: '/material/wood/wood_fine_13/wood_fine_13-normal.webp', }, mapProperties: { color: '#ffffff', - roughness: 0.122, + roughness: 0.45, metalness: 0, repeatX: 1, repeatY: 1, @@ -316,8 +370,119 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ normalScaleX: 1, normalScaleY: 1, emissiveIntensity: 1, - displacementScale: 0.02, - transparent: true, + displacementScale: 0, + transparent: false, + flipY: true, + bumpScale: 1, + emissiveColor: '#000000', + aoMapIntensity: 1, + side: 0, + opacity: 1, + lightMapIntensity: 1, + }, + }, + }, + { + id: 'wood-woodfine2', + label: 'Wood Fine 2', + category: 'wood', + description: 'Fine wood finish', + previewThumbnailUrl: '/material/wood/wood_fine_2/wood_fine_2-diffuse.webp', + preset: { + maps: { + albedoMap: '/material/wood/wood_fine_2/wood_fine_2-diffuse.webp', + aoMap: '/material/wood/wood_fine_2/wood_fine_2-ao.webp', + metalnessMap: '/material/wood/wood_fine_2/wood_fine_2-specular.webp', + normalMap: '/material/wood/wood_fine_2/wood_fine_2-normal.webp', + }, + mapProperties: { + color: '#ffffff', + roughness: 0.45, + metalness: 0, + repeatX: 1, + repeatY: 1, + rotation: 0, + wrapS: 'Repeat', + wrapT: 'Repeat', + normalScaleX: 1, + normalScaleY: 1, + emissiveIntensity: 1, + displacementScale: 0, + transparent: false, + flipY: true, + bumpScale: 1, + emissiveColor: '#000000', + aoMapIntensity: 1, + side: 0, + opacity: 1, + lightMapIntensity: 1, + }, + }, + }, + { + id: 'wood-woodfine22', + label: 'Wood Fine 22', + category: 'wood', + description: 'Fine wood finish', + previewThumbnailUrl: '/material/wood/wood_fine_22/wood_fine_22-diffuse.webp', + preset: { + maps: { + albedoMap: '/material/wood/wood_fine_22/wood_fine_22-diffuse.webp', + aoMap: '/material/wood/wood_fine_22/wood_fine_22-ao.webp', + metalnessMap: '/material/wood/wood_fine_22/wood_fine_22-specular.webp', + normalMap: '/material/wood/wood_fine_22/wood_fine_22-normal.webp', + }, + mapProperties: { + color: '#ffffff', + roughness: 0.45, + metalness: 0, + repeatX: 1, + repeatY: 1, + rotation: 0, + wrapS: 'Repeat', + wrapT: 'Repeat', + normalScaleX: 1, + normalScaleY: 1, + emissiveIntensity: 1, + displacementScale: 0, + transparent: false, + flipY: true, + bumpScale: 1, + emissiveColor: '#000000', + aoMapIntensity: 1, + side: 0, + opacity: 1, + lightMapIntensity: 1, + }, + }, + }, + { + id: 'wood-woodfine24', + label: 'Wood Fine 24', + category: 'wood', + description: 'Fine wood finish', + previewThumbnailUrl: '/material/wood/wood_fine_24/wood_fine_24-diffuse.webp', + preset: { + maps: { + albedoMap: '/material/wood/wood_fine_24/wood_fine_24-diffuse.webp', + aoMap: '/material/wood/wood_fine_24/wood_fine_24-ao.webp', + metalnessMap: '/material/wood/wood_fine_24/wood_fine_24-specular.webp', + normalMap: '/material/wood/wood_fine_24/wood_fine_24-normal.webp', + }, + mapProperties: { + color: '#ffffff', + roughness: 0.45, + metalness: 0, + repeatX: 1, + repeatY: 1, + rotation: 0, + wrapS: 'Repeat', + wrapT: 'Repeat', + normalScaleX: 1, + normalScaleY: 1, + emissiveIntensity: 1, + displacementScale: 0, + transparent: false, flipY: true, bumpScale: 1, emissiveColor: '#000000', @@ -329,19 +494,23 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ }, }, { - id: 'wall-parquet1', - label: 'Parquet', - category: 'parquet', + id: 'wood-woodparquet14', + label: 'Wood Parquet 14', + category: 'wood', description: 'Parquet wood finish', - previewThumbnailUrl: '/material/parquet1/parquet_thumnail.webp', + previewThumbnailUrl: '/material/wood/wood_parquet_14/woodparquet_14_basecolor.webp', preset: { maps: { - albedoMap: '/material/parquet1/albedoMap_parquet.jpg', + albedoMap: '/material/wood/wood_parquet_14/woodparquet_14_basecolor.webp', + aoMap: '/material/wood/wood_parquet_14/woodparquet_14_ambientocclusion.webp', + metalnessMap: '/material/wood/wood_parquet_14/woodparquet_14_metallic.webp', + normalMap: '/material/wood/wood_parquet_14/woodparquet_14_normal.webp', + roughnessMap: '/material/wood/wood_parquet_14/woodparquet_14_roughness.webp', }, mapProperties: { color: '#ffffff', - roughness: 0.644, - metalness: 0.4, + roughness: 0.5, + metalness: 0, repeatX: 1, repeatY: 1, rotation: 0, @@ -350,8 +519,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ normalScaleX: 1, normalScaleY: 1, emissiveIntensity: 1, - displacementScale: 0.02, - transparent: true, + displacementScale: 0, + transparent: false, flipY: true, bumpScale: 1, emissiveColor: '#000000', @@ -363,18 +532,21 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ }, }, { - id: 'wall-parquet2', - label: 'Parquet', - category: 'parquet', - description: 'Soft parquet finish', - previewThumbnailUrl: '/material/parquet2/parquet2_thumbnail.webp', + id: 'wood-woodenparquet11', + label: 'Wooden Parquet 11', + category: 'wood', + description: 'Parquet wood finish', + previewThumbnailUrl: '/material/wood/wooden_parquet_11/Classic Parquet_11_baseColor.webp', preset: { maps: { - albedoMap: '/material/parquet2/albedoMap_parquet.jpg', + albedoMap: '/material/wood/wooden_parquet_11/Classic Parquet_11_baseColor.webp', + metalnessMap: '/material/wood/wooden_parquet_11/Classic Parquet_11_specularLevel.webp', + normalMap: '/material/wood/wooden_parquet_11/Classic Parquet_11_normal.webp', + roughnessMap: '/material/wood/wooden_parquet_11/Classic Parquet_11_roughness.webp', }, mapProperties: { color: '#ffffff', - roughness: 0.6, + roughness: 0.5, metalness: 0, repeatX: 1, repeatY: 1, @@ -384,8 +556,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ normalScaleX: 1, normalScaleY: 1, emissiveIntensity: 1, - displacementScale: 0.02, - transparent: true, + displacementScale: 0, + transparent: false, flipY: true, bumpScale: 1, emissiveColor: '#000000', @@ -397,19 +569,21 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ }, }, { - id: 'wall-wallpaper1', - label: 'Wallpaper', - category: 'wallpaper', - description: 'Soft wallpaper finish', - previewThumbnailUrl: '/material/wallpaper1/wallpaper1_thumbnail.webp', + id: 'wood-woodparquet121', + label: 'Wood Parquet 121', + category: 'wood', + description: 'Parquet wood finish', + previewThumbnailUrl: '/material/wood/woodparquet_121/woodparquet_121_basecolor.webp', preset: { maps: { - albedoMap: '/material/wallpaper1/albedoMap_1.webp', - normalMap: '/material/wallpaper1/normalMap_NormalMap.webp', + albedoMap: '/material/wood/woodparquet_121/woodparquet_121_basecolor.webp', + aoMap: '/material/wood/woodparquet_121/woodparquet_121_ambientocclusion.webp', + normalMap: '/material/wood/woodparquet_121/woodparquet_121_normal.webp', + roughnessMap: '/material/wood/woodparquet_121/woodparquet_121_roughness.webp', }, mapProperties: { color: '#ffffff', - roughness: 0.911, + roughness: 0.5, metalness: 0, repeatX: 1, repeatY: 1, @@ -419,8 +593,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ normalScaleX: 1, normalScaleY: 1, emissiveIntensity: 1, - displacementScale: 0.02, - transparent: true, + displacementScale: 0, + transparent: false, flipY: true, bumpScale: 1, emissiveColor: '#000000', @@ -432,19 +606,23 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ }, }, { - id: 'wall-wallpaper2', - label: 'Wallpaper', - category: 'wallpaper', - description: 'Decorative wallpaper finish', - previewThumbnailUrl: '/material/wallpaper2/wallpaper2_thumnail.webp', + id: 'wood-woodparquet56', + label: 'Wood Parquet 56', + category: 'wood', + description: 'Parquet wood finish', + previewThumbnailUrl: '/material/wood/woodparquet_56/woodparquet_56_basecolor.webp', preset: { maps: { - albedoMap: '/material/wallpaper2/albedoMap_5.webp', + albedoMap: '/material/wood/woodparquet_56/woodparquet_56_basecolor.webp', + aoMap: '/material/wood/woodparquet_56/woodparquet_56_ambientocclusion.webp', + metalnessMap: '/material/wood/woodparquet_56/woodparquet_56_metallic.webp', + normalMap: '/material/wood/woodparquet_56/woodparquet_56_normal.webp', + roughnessMap: '/material/wood/woodparquet_56/woodparquet_56_roughness.webp', }, mapProperties: { color: '#ffffff', - roughness: 0.889, - metalness: 0.255, + roughness: 0.5, + metalness: 0, repeatX: 1, repeatY: 1, rotation: 0, @@ -453,8 +631,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ normalScaleX: 1, normalScaleY: 1, emissiveIntensity: 1, - displacementScale: 0.02, - transparent: true, + displacementScale: 0, + transparent: false, flipY: true, bumpScale: 1, emissiveColor: '#000000', @@ -466,19 +644,23 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ }, }, { - id: 'wall-wallpaper3', - label: 'Wallpaper', - category: 'wallpaper', - description: 'Patterned wallpaper finish', - previewThumbnailUrl: '/material/wallpaper3/wallpaper3_thumbnail.webp', + id: 'wood-woodparquet65', + label: 'Wood Parquet 65', + category: 'wood', + description: 'Parquet wood finish', + previewThumbnailUrl: '/material/wood/woodparquet_65/woodparquet_65_BaseColor.webp', preset: { maps: { - albedoMap: '/material/wallpaper3/albedoMap_wallpaper3.avif', + albedoMap: '/material/wood/woodparquet_65/woodparquet_65_BaseColor.webp', + aoMap: '/material/wood/woodparquet_65/woodparquet_65_AmbientOcclusion.webp', + metalnessMap: '/material/wood/woodparquet_65/woodparquet_65_Metallic.webp', + normalMap: '/material/wood/woodparquet_65/woodparquet_65_Normal.webp', + roughnessMap: '/material/wood/woodparquet_65/woodparquet_65_Roughness.webp', }, mapProperties: { color: '#ffffff', - roughness: 0.887, - metalness: 0.35, + roughness: 0.5, + metalness: 0, repeatX: 1, repeatY: 1, rotation: 0, @@ -487,8 +669,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ normalScaleX: 1, normalScaleY: 1, emissiveIntensity: 1, - displacementScale: 0.02, - transparent: true, + displacementScale: 0, + transparent: false, flipY: true, bumpScale: 1, emissiveColor: '#000000', @@ -500,15 +682,1638 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ }, }, { - id: 'preset-white', - label: 'White', - category: 'other', - description: 'Clean painted finish', - previewColor: '#ffffff', + id: 'wood-woodparquet99', + label: 'Wood Parquet 99', + category: 'wood', + description: 'Parquet wood finish', + previewThumbnailUrl: '/material/wood/woodparquet_99/woodparquet_99_basecolor.webp', preset: { - maps: {}, + maps: { + albedoMap: '/material/wood/woodparquet_99/woodparquet_99_basecolor.webp', + aoMap: '/material/wood/woodparquet_99/woodparquet_99_ambientocclusion.webp', + metalnessMap: '/material/wood/woodparquet_99/woodparquet_99_metallic.webp', + normalMap: '/material/wood/woodparquet_99/woodparquet_99_normal.webp', + roughnessMap: '/material/wood/woodparquet_99/woodparquet_99_roughness.webp', + }, + mapProperties: { + color: '#ffffff', + roughness: 0.5, + metalness: 0, + repeatX: 1, + repeatY: 1, + rotation: 0, + wrapS: 'Repeat', + wrapT: 'Repeat', + normalScaleX: 1, + normalScaleY: 1, + emissiveIntensity: 1, + displacementScale: 0, + transparent: false, + flipY: true, + bumpScale: 1, + emissiveColor: '#000000', + aoMapIntensity: 1, + side: 0, + opacity: 1, + lightMapIntensity: 1, + }, + }, + }, + { + id: 'wood-woodplank19', + label: 'Wood Plank 19', + category: 'wood', + description: 'Wood plank finish', + previewThumbnailUrl: '/material/wood/woodplank_19/woodplank_19_basecolor.webp', + preset: { + maps: { + albedoMap: '/material/wood/woodplank_19/woodplank_19_basecolor.webp', + aoMap: '/material/wood/woodplank_19/woodplank_19_ambientocclusion.webp', + normalMap: '/material/wood/woodplank_19/woodplank_19_normal.webp', + roughnessMap: '/material/wood/woodplank_19/woodplank_19_roughness.webp', + }, + mapProperties: { + color: '#ffffff', + roughness: 0.5, + metalness: 0, + repeatX: 1, + repeatY: 1, + rotation: 0, + wrapS: 'Repeat', + wrapT: 'Repeat', + normalScaleX: 1, + normalScaleY: 1, + emissiveIntensity: 1, + displacementScale: 0, + transparent: false, + flipY: true, + bumpScale: 1, + emissiveColor: '#000000', + aoMapIntensity: 1, + side: 0, + opacity: 1, + lightMapIntensity: 1, + }, + }, + }, + { + id: 'wood-woodplank48', + label: 'Wood Plank 48', + category: 'wood', + description: 'Wood plank finish', + previewThumbnailUrl: '/material/wood/woodplank_48/woodplank_48_BaseColor.webp', + preset: { + maps: { + albedoMap: '/material/wood/woodplank_48/woodplank_48_BaseColor.webp', + aoMap: '/material/wood/woodplank_48/woodplank_48_AmbientOcclusion.webp', + normalMap: '/material/wood/woodplank_48/woodplank_48_Normal.webp', + roughnessMap: '/material/wood/woodplank_48/woodplank_48_Roughness.webp', + }, mapProperties: { color: '#ffffff', + roughness: 0.5, + metalness: 0, + repeatX: 1, + repeatY: 1, + rotation: 0, + wrapS: 'Repeat', + wrapT: 'Repeat', + normalScaleX: 1, + normalScaleY: 1, + emissiveIntensity: 1, + displacementScale: 0, + transparent: false, + flipY: true, + bumpScale: 1, + emissiveColor: '#000000', + aoMapIntensity: 1, + side: 0, + opacity: 1, + lightMapIntensity: 1, + }, + }, + }, + { + id: 'flooring-tile85a', + label: 'Quarry Tile', + category: 'flooring', + description: 'Floor tile finish', + previewThumbnailUrl: '/material/flooring/tile_quarry/tile_quarry_basecolor.webp', + preset: { + maps: { + albedoMap: '/material/flooring/tile_quarry/tile_quarry_basecolor.webp', + aoMap: '/material/flooring/tile_quarry/tile_quarry_ambientocclusion.webp', + normalMap: '/material/flooring/tile_quarry/tile_quarry_normal.webp', + roughnessMap: '/material/flooring/tile_quarry/tile_quarry_roughness.webp', + }, + mapProperties: { + color: '#ffffff', + roughness: 0.5, + metalness: 0, + repeatX: 0.3, + repeatY: 0.3, + rotation: 0, + wrapS: 'Repeat', + wrapT: 'Repeat', + normalScaleX: 1, + normalScaleY: 1, + emissiveIntensity: 1, + displacementScale: 0, + transparent: false, + flipY: true, + bumpScale: 1, + emissiveColor: '#000000', + aoMapIntensity: 1, + side: 0, + opacity: 1, + lightMapIntensity: 1, + }, + }, + }, + { + id: 'flooring-rusticbrick', + label: 'Rustic Brick', + category: 'flooring', + description: 'Brick finish', + previewThumbnailUrl: '/material/flooring/brick_wall_rustic/brick_wall_rustic_basecolor.jpg', + preset: { + maps: { + albedoMap: '/material/flooring/brick_wall_rustic/brick_wall_rustic_basecolor.jpg', + aoMap: '/material/flooring/brick_wall_rustic/brick_wall_rustic_ambientocclusion.jpg', + metalnessMap: '/material/flooring/brick_wall_rustic/brick_wall_rustic_metallic.jpg', + normalMap: '/material/flooring/brick_wall_rustic/brick_wall_rustic_normal.jpg', + roughnessMap: '/material/flooring/brick_wall_rustic/brick_wall_rustic_roughness.jpg', + }, + mapProperties: { + color: '#ffffff', + roughness: 0.5, + metalness: 0, + repeatX: 1.5, + repeatY: 1.5, + rotation: 0, + wrapS: 'Repeat', + wrapT: 'Repeat', + normalScaleX: 1, + normalScaleY: 1, + emissiveIntensity: 1, + displacementScale: 0, + transparent: false, + flipY: true, + bumpScale: 1, + emissiveColor: '#000000', + aoMapIntensity: 1, + side: 0, + opacity: 1, + lightMapIntensity: 1, + }, + }, + }, + { + id: 'flooring-agedbrick', + label: 'Aged Brick', + category: 'flooring', + description: 'Brick finish', + previewThumbnailUrl: '/material/flooring/brick_wall_aged/brick_wall_aged_basecolor.jpg', + preset: { + maps: { + albedoMap: '/material/flooring/brick_wall_aged/brick_wall_aged_basecolor.jpg', + aoMap: '/material/flooring/brick_wall_aged/brick_wall_aged_ambientocclusion.jpg', + metalnessMap: '/material/flooring/brick_wall_aged/brick_wall_aged_metallic.jpg', + normalMap: '/material/flooring/brick_wall_aged/brick_wall_aged_normal.jpg', + roughnessMap: '/material/flooring/brick_wall_aged/brick_wall_aged_roughness.jpg', + }, + mapProperties: { + color: '#ffffff', + roughness: 0.5, + metalness: 0, + repeatX: 1.5, + repeatY: 1.5, + rotation: 0, + wrapS: 'Repeat', + wrapT: 'Repeat', + normalScaleX: 1, + normalScaleY: 1, + emissiveIntensity: 1, + displacementScale: 0, + transparent: false, + flipY: true, + bumpScale: 1, + emissiveColor: '#000000', + aoMapIntensity: 1, + side: 0, + opacity: 1, + lightMapIntensity: 1, + }, + }, + }, + { + id: 'flooring-weatheredbrick', + label: 'Weathered Brick', + category: 'flooring', + description: 'Brick finish', + previewThumbnailUrl: + '/material/flooring/brick_wall_weathered/brick_wall_weathered_basecolor.jpg', + preset: { + maps: { + albedoMap: '/material/flooring/brick_wall_weathered/brick_wall_weathered_basecolor.jpg', + aoMap: '/material/flooring/brick_wall_weathered/brick_wall_weathered_ambientocclusion.jpg', + normalMap: '/material/flooring/brick_wall_weathered/brick_wall_weathered_normal.jpg', + roughnessMap: '/material/flooring/brick_wall_weathered/brick_wall_weathered_roughness.jpg', + }, + mapProperties: { + color: '#ffffff', + roughness: 0.5, + metalness: 0, + repeatX: 1, + repeatY: 1, + rotation: 0, + wrapS: 'Repeat', + wrapT: 'Repeat', + normalScaleX: 1, + normalScaleY: 1, + emissiveIntensity: 1, + displacementScale: 0, + transparent: false, + flipY: true, + bumpScale: 1, + emissiveColor: '#000000', + aoMapIntensity: 1, + side: 0, + opacity: 1, + lightMapIntensity: 1, + }, + }, + }, + { + id: 'flooring-garagedoor', + label: 'Garage Panel', + category: 'flooring', + description: 'Panel finish', + previewThumbnailUrl: '/material/flooring/garage_panel/garage_panel_diffuse.jpg', + preset: { + maps: { + albedoMap: '/material/flooring/garage_panel/garage_panel_diffuse.jpg', + aoMap: '/material/flooring/garage_panel/garage_panel_ao.jpg', + metalnessMap: '/material/flooring/garage_panel/garage_panel_specular.jpg', + normalMap: '/material/flooring/garage_panel/garage_panel_normal.jpg', + }, + mapProperties: { + color: '#ffffff', + roughness: 0.45, + metalness: 0, + repeatX: 1, + repeatY: 1, + rotation: 0, + wrapS: 'Repeat', + wrapT: 'Repeat', + normalScaleX: 1, + normalScaleY: 1, + emissiveIntensity: 1, + displacementScale: 0, + transparent: false, + flipY: true, + bumpScale: 1, + emissiveColor: '#000000', + aoMapIntensity: 1, + side: 0, + opacity: 1, + lightMapIntensity: 1, + }, + }, + }, + { + id: 'flooring-greenlabradorite', + label: 'Green Labradorite', + category: 'flooring', + description: 'Stone flooring finish', + previewThumbnailUrl: '/material/flooring/green_labradorite/green_labradorite_diffuse.jpg', + preset: { + maps: { + albedoMap: '/material/flooring/green_labradorite/green_labradorite_diffuse.jpg', + aoMap: '/material/flooring/green_labradorite/green_labradorite_ao.jpg', + metalnessMap: '/material/flooring/green_labradorite/green_labradorite_specular.jpg', + normalMap: '/material/flooring/green_labradorite/green_labradorite_normal.jpg', + }, + mapProperties: { + color: '#ffffff', + roughness: 0.35, + metalness: 0, + repeatX: 0.6, + repeatY: 1, + rotation: 0, + wrapS: 'Repeat', + wrapT: 'Repeat', + normalScaleX: 1, + normalScaleY: 1, + emissiveIntensity: 1, + displacementScale: 0, + transparent: false, + flipY: true, + bumpScale: 1, + emissiveColor: '#000000', + aoMapIntensity: 1, + side: 0, + opacity: 1, + lightMapIntensity: 1, + }, + }, + }, + { + id: 'flooring-ground13', + label: 'Earth Ground', + category: 'flooring', + description: 'Ground surface finish', + previewThumbnailUrl: '/material/flooring/ground_earth/ground_earth_basecolor.jpg', + preset: { + maps: { + albedoMap: '/material/flooring/ground_earth/ground_earth_basecolor.jpg', + aoMap: '/material/flooring/ground_earth/ground_earth_ambientocclusion.jpg', + metalnessMap: '/material/flooring/ground_earth/ground_earth_metallic.jpg', + normalMap: '/material/flooring/ground_earth/ground_earth_normal.jpg', + roughnessMap: '/material/flooring/ground_earth/ground_earth_roughness.jpg', + }, + mapProperties: { + color: '#ffffff', + roughness: 0.5, + metalness: 0, + repeatX: 1, + repeatY: 1, + rotation: 0, + wrapS: 'Repeat', + wrapT: 'Repeat', + normalScaleX: 1, + normalScaleY: 1, + emissiveIntensity: 1, + displacementScale: 0, + transparent: false, + flipY: true, + bumpScale: 1, + emissiveColor: '#000000', + aoMapIntensity: 1, + side: 0, + opacity: 1, + lightMapIntensity: 1, + }, + }, + }, + { + id: 'flooring-pooltiles', + label: 'Pool Tiles', + category: 'flooring', + description: 'Pool tile finish', + previewThumbnailUrl: '/material/flooring/pool_tiles/pool_tiles_diffuse.jpg', + preset: { + maps: { + albedoMap: '/material/flooring/pool_tiles/pool_tiles_diffuse.jpg', + aoMap: '/material/flooring/pool_tiles/pool_tiles_ao.jpg', + metalnessMap: '/material/flooring/pool_tiles/pool_tiles_specular.jpg', + normalMap: '/material/flooring/pool_tiles/pool_tiles_normal.jpg', + }, + mapProperties: { + color: '#ffffff', + roughness: 0.45, + metalness: 0, + repeatX: 1, + repeatY: 1, + rotation: 0, + wrapS: 'Repeat', + wrapT: 'Repeat', + normalScaleX: 1, + normalScaleY: 1, + emissiveIntensity: 1, + displacementScale: 0, + transparent: false, + flipY: true, + bumpScale: 1, + emissiveColor: '#000000', + aoMapIntensity: 1, + side: 0, + opacity: 1, + lightMapIntensity: 1, + }, + }, + }, + { + id: 'flooring-tiles3', + label: 'Checker Tiles', + category: 'flooring', + description: 'Tile flooring finish', + previewThumbnailUrl: '/material/flooring/tiles_checker/tiles_checker_diffuse.jpg', + preset: { + maps: { + albedoMap: '/material/flooring/tiles_checker/tiles_checker_diffuse.jpg', + aoMap: '/material/flooring/tiles_checker/tiles_checker_ao.jpg', + metalnessMap: '/material/flooring/tiles_checker/tiles_checker_specular.jpg', + normalMap: '/material/flooring/tiles_checker/tiles_checker_normal.jpg', + }, + mapProperties: { + color: '#ffffff', + roughness: 0.45, + metalness: 0, + repeatX: 1, + repeatY: 1, + rotation: 0, + wrapS: 'Repeat', + wrapT: 'Repeat', + normalScaleX: 1, + normalScaleY: 1, + emissiveIntensity: 1, + displacementScale: 0, + transparent: false, + flipY: true, + bumpScale: 1, + emissiveColor: '#000000', + aoMapIntensity: 1, + side: 0, + opacity: 1, + lightMapIntensity: 1, + }, + }, + }, + { + id: 'flooring-tiles4', + label: 'Grid Tiles', + category: 'flooring', + description: 'Tile flooring finish', + previewThumbnailUrl: '/material/flooring/tiles_grid/tiles_grid_diffuse.jpg', + preset: { + maps: { + albedoMap: '/material/flooring/tiles_grid/tiles_grid_diffuse.jpg', + aoMap: '/material/flooring/tiles_grid/tiles_grid_ao.jpg', + metalnessMap: '/material/flooring/tiles_grid/tiles_grid_specular.jpg', + normalMap: '/material/flooring/tiles_grid/tiles_grid_normal.jpg', + }, + mapProperties: { + color: '#ffffff', + roughness: 0.45, + metalness: 0, + repeatX: 1, + repeatY: 1, + rotation: 0, + wrapS: 'Repeat', + wrapT: 'Repeat', + normalScaleX: 1, + normalScaleY: 1, + emissiveIntensity: 1, + displacementScale: 0, + transparent: false, + flipY: true, + bumpScale: 1, + emissiveColor: '#000000', + aoMapIntensity: 1, + side: 0, + opacity: 1, + lightMapIntensity: 1, + }, + }, + }, + { + id: 'flooring-wallstone1', + label: 'Stone Wall', + category: 'flooring', + description: 'Stone finish', + previewThumbnailUrl: '/material/flooring/stone_wall/stone_wall_diffuse.webp', + preset: { + maps: { + albedoMap: '/material/flooring/stone_wall/stone_wall_diffuse.webp', + aoMap: '/material/flooring/stone_wall/stone_wall_ao.webp', + metalnessMap: '/material/flooring/stone_wall/stone_wall_specular.webp', + normalMap: '/material/flooring/stone_wall/stone_wall_normal.webp', + }, + mapProperties: { + color: '#ffffff', + roughness: 0.45, + metalness: 0, + repeatX: 1, + repeatY: 1, + rotation: 0, + wrapS: 'Repeat', + wrapT: 'Repeat', + normalScaleX: 1, + normalScaleY: 1, + emissiveIntensity: 1, + displacementScale: 0, + transparent: false, + flipY: true, + bumpScale: 1, + emissiveColor: '#000000', + aoMapIntensity: 1, + side: 0, + opacity: 1, + lightMapIntensity: 1, + }, + }, + }, + { + id: 'flooring-woodenceramic3', + label: 'Wooden Ceramic 3', + category: 'flooring', + description: 'Wood-look ceramic flooring finish', + previewThumbnailUrl: '/material/flooring/wooden_ceramic_3/wooden_ceramic-diffuse.webp', + preset: { + maps: { + albedoMap: '/material/flooring/wooden_ceramic_3/wooden_ceramic-diffuse.webp', + aoMap: '/material/flooring/wooden_ceramic_3/wooden_ceramic-ao.webp', + metalnessMap: '/material/flooring/wooden_ceramic_3/wooden_ceramic-specular.webp', + normalMap: '/material/flooring/wooden_ceramic_3/wooden_ceramic-normal.webp', + }, + mapProperties: { + color: '#ffffff', + roughness: 0.45, + metalness: 0, + repeatX: 1, + repeatY: 1, + rotation: 0, + wrapS: 'Repeat', + wrapT: 'Repeat', + normalScaleX: 1, + normalScaleY: 1, + emissiveIntensity: 1, + displacementScale: 0, + transparent: false, + flipY: true, + bumpScale: 1, + emissiveColor: '#000000', + aoMapIntensity: 1, + side: 0, + opacity: 1, + lightMapIntensity: 1, + }, + }, + }, + { + id: 'flooring-ceramic53', + label: 'Ceramic Mosaic', + category: 'flooring', + description: 'Ceramic flooring finish', + previewThumbnailUrl: '/material/flooring/ceramic_mosaic/ceramic_mosaic_basecolor.jpg', + preset: { + maps: { + albedoMap: '/material/flooring/ceramic_mosaic/ceramic_mosaic_basecolor.jpg', + aoMap: '/material/flooring/ceramic_mosaic/ceramic_mosaic_ambientocclusion.jpg', + metalnessMap: '/material/flooring/ceramic_mosaic/ceramic_mosaic_metallic.jpg', + normalMap: '/material/flooring/ceramic_mosaic/ceramic_mosaic_normal.png', + roughnessMap: '/material/flooring/ceramic_mosaic/ceramic_mosaic_roughness.jpg', + }, + mapProperties: { + color: '#ffffff', + roughness: 0.5, + metalness: 0, + repeatX: 0.5, + repeatY: 0.5, + rotation: 0, + wrapS: 'Repeat', + wrapT: 'Repeat', + normalScaleX: 1, + normalScaleY: 1, + emissiveIntensity: 1, + displacementScale: 0, + transparent: false, + flipY: true, + bumpScale: 1, + emissiveColor: '#000000', + aoMapIntensity: 1, + side: 0, + opacity: 1, + lightMapIntensity: 1, + }, + }, + }, + { + id: 'flooring-terrazzo19', + label: 'Terrazzo', + category: 'flooring', + description: 'Terrazzo flooring finish', + previewThumbnailUrl: '/material/flooring/terrazzo/terrazzo_basecolor.jpg', + preset: { + maps: { + albedoMap: '/material/flooring/terrazzo/terrazzo_basecolor.jpg', + metalnessMap: '/material/flooring/terrazzo/terrazzo_metallic.jpg', + normalMap: '/material/flooring/terrazzo/terrazzo_normal.jpg', + roughnessMap: '/material/flooring/terrazzo/terrazzo_roughness.jpg', + }, + mapProperties: { + color: '#ffffff', + roughness: 0.5, + metalness: 0, + repeatX: 0.5, + repeatY: 0.5, + rotation: 0, + wrapS: 'Repeat', + wrapT: 'Repeat', + normalScaleX: 1, + normalScaleY: 1, + emissiveIntensity: 1, + displacementScale: 0, + transparent: false, + flipY: true, + bumpScale: 1, + emissiveColor: '#000000', + aoMapIntensity: 1, + side: 0, + opacity: 1, + lightMapIntensity: 1, + }, + }, + }, + { + id: 'flooring-tile79', + label: 'Stone Tile', + category: 'flooring', + description: 'Floor tile finish', + previewThumbnailUrl: '/material/flooring/tile_stone/tile_stone_basecolor.webp', + preset: { + maps: { + albedoMap: '/material/flooring/tile_stone/tile_stone_basecolor.webp', + aoMap: '/material/flooring/tile_stone/tile_stone_ambientocclusion.webp', + normalMap: '/material/flooring/tile_stone/tile_stone_normal.webp', + roughnessMap: '/material/flooring/tile_stone/tile_stone_roughness.webp', + }, + mapProperties: { + color: '#ffffff', + roughness: 0.5, + metalness: 0, + repeatX: 0.5, + repeatY: 0.5, + rotation: 0, + wrapS: 'Repeat', + wrapT: 'Repeat', + normalScaleX: 1, + normalScaleY: 1, + emissiveIntensity: 1, + displacementScale: 0, + transparent: false, + flipY: true, + bumpScale: 1, + emissiveColor: '#000000', + aoMapIntensity: 1, + side: 0, + opacity: 1, + lightMapIntensity: 1, + }, + }, + }, + { + id: 'flooring-tile86', + label: 'Terracotta Tile', + category: 'flooring', + description: 'Floor tile finish', + previewThumbnailUrl: '/material/flooring/tile_terracotta/tile_terracotta_basecolor.webp', + preset: { + maps: { + albedoMap: '/material/flooring/tile_terracotta/tile_terracotta_basecolor.webp', + aoMap: '/material/flooring/tile_terracotta/tile_terracotta_ambientocclusion.webp', + normalMap: '/material/flooring/tile_terracotta/tile_terracotta_normal.webp', + roughnessMap: '/material/flooring/tile_terracotta/tile_terracotta_roughness.webp', + }, + mapProperties: { + color: '#ffffff', + roughness: 0.5, + metalness: 0, + repeatX: 0.5, + repeatY: 0.5, + rotation: 0, + wrapS: 'Repeat', + wrapT: 'Repeat', + normalScaleX: 1, + normalScaleY: 1, + emissiveIntensity: 1, + displacementScale: 0, + transparent: false, + flipY: true, + bumpScale: 1, + emissiveColor: '#000000', + aoMapIntensity: 1, + side: 0, + opacity: 1, + lightMapIntensity: 1, + }, + }, + }, + { + id: 'flooring-greenquartzitea', + label: 'Green Quartzite A', + category: 'flooring', + description: 'Green quartzite flooring finish', + previewThumbnailUrl: + '/material/flooring/green_glass_quartzite/green_glass_quartzite_diffuse.jpg', + preset: { + maps: { + albedoMap: '/material/flooring/green_glass_quartzite/green_glass_quartzite_diffuse.jpg', + aoMap: '/material/flooring/green_glass_quartzite/green_glass_quartzite_ao.jpg', + metalnessMap: '/material/flooring/green_glass_quartzite/green_glass_quartzite_specular.jpg', + normalMap: '/material/flooring/green_glass_quartzite/green_glass_quartzite_normal.jpg', + }, + mapProperties: { + color: '#ffffff', + roughness: 0.35, + metalness: 0, + repeatX: 1, + repeatY: 1, + rotation: 0, + wrapS: 'Repeat', + wrapT: 'Repeat', + normalScaleX: 1, + normalScaleY: 1, + emissiveIntensity: 1, + displacementScale: 0, + transparent: false, + flipY: true, + bumpScale: 1, + emissiveColor: '#000000', + aoMapIntensity: 1, + side: 0, + opacity: 1, + lightMapIntensity: 1, + }, + }, + }, + { + id: 'flooring-darkceramic22', + label: 'Dark Ceramic Grunge', + category: 'flooring', + description: 'Dark ceramic flooring finish', + previewThumbnailUrl: '/material/flooring/dark_ceramic_grunge/dark_ceramic_grunge_basecolor.jpg', + preset: { + maps: { + albedoMap: '/material/flooring/dark_ceramic_grunge/dark_ceramic_grunge_basecolor.jpg', + aoMap: '/material/flooring/dark_ceramic_grunge/dark_ceramic_grunge_ambientocclusion.jpg', + metalnessMap: '/material/flooring/dark_ceramic_grunge/dark_ceramic_grunge_metallic.jpg', + normalMap: '/material/flooring/dark_ceramic_grunge/dark_ceramic_grunge_normal.jpg', + roughnessMap: '/material/flooring/dark_ceramic_grunge/dark_ceramic_grunge_roughness.jpg', + }, + mapProperties: { + color: '#ffffff', + roughness: 0.5, + metalness: 0, + repeatX: 1, + repeatY: 1, + rotation: 0, + wrapS: 'Repeat', + wrapT: 'Repeat', + normalScaleX: 1, + normalScaleY: 1, + emissiveIntensity: 1, + displacementScale: 0, + transparent: false, + flipY: true, + bumpScale: 1, + emissiveColor: '#000000', + aoMapIntensity: 1, + side: 0, + opacity: 1, + lightMapIntensity: 1, + }, + }, + }, + { + id: 'flooring-lightceramic24', + label: 'Light Ceramic Grunge', + category: 'flooring', + description: 'Light ceramic flooring finish', + previewThumbnailUrl: + '/material/flooring/light_ceramic_grunge/light_ceramic_grunge_basecolor.jpg', + preset: { + maps: { + albedoMap: '/material/flooring/light_ceramic_grunge/light_ceramic_grunge_basecolor.jpg', + aoMap: '/material/flooring/light_ceramic_grunge/light_ceramic_grunge_ambientocclusion.jpg', + metalnessMap: '/material/flooring/light_ceramic_grunge/light_ceramic_grunge_metallic.jpg', + normalMap: '/material/flooring/light_ceramic_grunge/light_ceramic_grunge_normal.jpg', + roughnessMap: '/material/flooring/light_ceramic_grunge/light_ceramic_grunge_roughness.jpg', + }, + mapProperties: { + color: '#ffffff', + roughness: 0.5, + metalness: 0, + repeatX: 1, + repeatY: 1, + rotation: 0, + wrapS: 'Repeat', + wrapT: 'Repeat', + normalScaleX: 1, + normalScaleY: 1, + emissiveIntensity: 1, + displacementScale: 0, + transparent: false, + flipY: true, + bumpScale: 1, + emissiveColor: '#000000', + aoMapIntensity: 1, + side: 0, + opacity: 1, + lightMapIntensity: 1, + }, + }, + }, + { + id: 'flooring-statuarettowhite', + label: 'Statuaretto White', + category: 'flooring', + description: 'White marble flooring finish', + previewThumbnailUrl: '/material/flooring/statuaretto/statuaretto_diffuse.jpg', + preset: { + maps: { + albedoMap: '/material/flooring/statuaretto/statuaretto_diffuse.jpg', + aoMap: '/material/flooring/statuaretto/statuaretto_ao.jpg', + metalnessMap: '/material/flooring/statuaretto/statuaretto_specular.jpg', + normalMap: '/material/flooring/statuaretto/statuaretto_normal.jpg', + }, + mapProperties: { + color: '#ffffff', + roughness: 0.35, + metalness: 0, + repeatX: 1, + repeatY: 1, + rotation: 0, + wrapS: 'Repeat', + wrapT: 'Repeat', + normalScaleX: 1, + normalScaleY: 1, + emissiveIntensity: 1, + displacementScale: 0, + transparent: false, + flipY: true, + bumpScale: 1, + emissiveColor: '#000000', + aoMapIntensity: 1, + side: 0, + opacity: 1, + lightMapIntensity: 1, + }, + }, + }, + { + id: 'flooring-tile20', + label: 'Mosaic Tile', + category: 'flooring', + description: 'Floor tile finish', + previewThumbnailUrl: '/material/flooring/tile_mosaic/tile_mosaic_basecolor.webp', + preset: { + maps: { + albedoMap: '/material/flooring/tile_mosaic/tile_mosaic_basecolor.webp', + aoMap: '/material/flooring/tile_mosaic/tile_mosaic_ambientocclusion.webp', + metalnessMap: '/material/flooring/tile_mosaic/tile_mosaic_metallic.webp', + normalMap: '/material/flooring/tile_mosaic/tile_mosaic_normal.webp', + roughnessMap: '/material/flooring/tile_mosaic/tile_mosaic_roughness.webp', + }, + mapProperties: { + color: '#ffffff', + roughness: 0.5, + metalness: 0, + repeatX: 1, + repeatY: 1, + rotation: 0, + wrapS: 'Repeat', + wrapT: 'Repeat', + normalScaleX: 1, + normalScaleY: 1, + emissiveIntensity: 1, + displacementScale: 0, + transparent: false, + flipY: true, + bumpScale: 1, + emissiveColor: '#000000', + aoMapIntensity: 1, + side: 0, + opacity: 1, + lightMapIntensity: 1, + }, + }, + }, + { + id: 'flooring-tile68', + label: 'Pattern Tile', + category: 'flooring', + description: 'Floor tile finish', + previewThumbnailUrl: '/material/flooring/tile_pattern/tile_pattern_basecolor.webp', + preset: { + maps: { + albedoMap: '/material/flooring/tile_pattern/tile_pattern_basecolor.webp', + aoMap: '/material/flooring/tile_pattern/tile_pattern_ambientocclusion.webp', + metalnessMap: '/material/flooring/tile_pattern/tile_pattern_metallic.webp', + normalMap: '/material/flooring/tile_pattern/tile_pattern_normal.webp', + roughnessMap: '/material/flooring/tile_pattern/tile_pattern_roughness.webp', + }, + mapProperties: { + color: '#ffffff', + roughness: 0.5, + metalness: 0, + repeatX: 1, + repeatY: 1, + rotation: 0, + wrapS: 'Repeat', + wrapT: 'Repeat', + normalScaleX: 1, + normalScaleY: 1, + emissiveIntensity: 1, + displacementScale: 0, + transparent: false, + flipY: true, + bumpScale: 1, + emissiveColor: '#000000', + aoMapIntensity: 1, + side: 0, + opacity: 1, + lightMapIntensity: 1, + }, + }, + }, + { + id: 'flooring-woodenceramic2', + label: 'Wooden Ceramic 2', + category: 'flooring', + description: 'Wood-look ceramic flooring finish', + previewThumbnailUrl: '/material/flooring/wooden_ceramic_2/wooden_ceramic-diffuse.webp', + preset: { + maps: { + albedoMap: '/material/flooring/wooden_ceramic_2/wooden_ceramic-diffuse.webp', + aoMap: '/material/flooring/wooden_ceramic_2/wooden_ceramic-ao.webp', + metalnessMap: '/material/flooring/wooden_ceramic_2/wooden_ceramic-specular.webp', + normalMap: '/material/flooring/wooden_ceramic_2/wooden_ceramic-normal.webp', + }, + mapProperties: { + color: '#ffffff', + roughness: 0.45, + metalness: 0, + repeatX: 1.5, + repeatY: 1.5, + rotation: 0, + wrapS: 'Repeat', + wrapT: 'Repeat', + normalScaleX: 1, + normalScaleY: 1, + emissiveIntensity: 1, + displacementScale: 0, + transparent: false, + flipY: true, + bumpScale: 1, + emissiveColor: '#000000', + aoMapIntensity: 1, + side: 0, + opacity: 1, + lightMapIntensity: 1, + }, + }, + }, + { + id: 'flooring-woodparquet76', + label: 'Wood Parquet', + category: 'flooring', + description: 'Wood parquet flooring finish', + previewThumbnailUrl: '/material/flooring/woodparquet/woodparquet_basecolor.webp', + preset: { + maps: { + albedoMap: '/material/flooring/woodparquet/woodparquet_basecolor.webp', + aoMap: '/material/flooring/woodparquet/woodparquet_ambientocclusion.webp', + metalnessMap: '/material/flooring/woodparquet/woodparquet_metallic.webp', + normalMap: '/material/flooring/woodparquet/woodparquet_normal.webp', + roughnessMap: '/material/flooring/woodparquet/woodparquet_roughness.webp', + }, + mapProperties: { + color: '#ffffff', + roughness: 0.5, + metalness: 0, + repeatX: 1, + repeatY: 1, + rotation: 0, + wrapS: 'Repeat', + wrapT: 'Repeat', + normalScaleX: 1, + normalScaleY: 1, + emissiveIntensity: 1, + displacementScale: 0, + transparent: false, + flipY: true, + bumpScale: 1, + emissiveColor: '#000000', + aoMapIntensity: 1, + side: 0, + opacity: 1, + lightMapIntensity: 1, + }, + }, + }, + { + id: 'roof-classicshingles', + label: 'Classic Shingles', + category: 'roof', + description: 'Classic roof shingle finish', + previewThumbnailUrl: + '/material/roofing/roof_shingles_classic/roof_shingles_classic_basecolor.webp', + preset: { + maps: { + albedoMap: '/material/roofing/roof_shingles_classic/roof_shingles_classic_basecolor.webp', + aoMap: + '/material/roofing/roof_shingles_classic/roof_shingles_classic_ambientocclusion.webp', + metalnessMap: '/material/roofing/roof_shingles_classic/roof_shingles_classic_metallic.webp', + normalMap: '/material/roofing/roof_shingles_classic/roof_shingles_classic_normal.webp', + roughnessMap: + '/material/roofing/roof_shingles_classic/roof_shingles_classic_roughness.webp', + }, + mapProperties: { + color: '#ffffff', + roughness: 1, + metalness: 0.15, + repeatX: 1, + repeatY: 1, + rotation: 0, + wrapS: 'Repeat', + wrapT: 'Repeat', + normalScaleX: 1, + normalScaleY: 1, + emissiveIntensity: 1, + displacementScale: 0, + transparent: false, + flipY: true, + bumpScale: 1, + emissiveColor: '#000000', + aoMapIntensity: 1, + side: 0, + opacity: 1, + lightMapIntensity: 1, + }, + }, + }, + { + id: 'roof-claytiles', + label: 'Clay Tiles', + category: 'roof', + description: 'Clay roof tile finish', + previewThumbnailUrl: '/material/roofing/roof_tiles_clay/roof_tiles_clay_basecolor.webp', + preset: { + maps: { + albedoMap: '/material/roofing/roof_tiles_clay/roof_tiles_clay_basecolor.webp', + aoMap: '/material/roofing/roof_tiles_clay/roof_tiles_clay_ambientocclusion.webp', + metalnessMap: '/material/roofing/roof_tiles_clay/roof_tiles_clay_metallic.png', + normalMap: '/material/roofing/roof_tiles_clay/roof_tiles_clay_normal.webp', + roughnessMap: '/material/roofing/roof_tiles_clay/roof_tiles_clay_roughness.webp', + }, + mapProperties: { + color: '#ffffff', + roughness: 1, + metalness: 0.1, + repeatX: 1, + repeatY: 1, + rotation: 0, + wrapS: 'Repeat', + wrapT: 'Repeat', + normalScaleX: 1, + normalScaleY: 1, + emissiveIntensity: 1, + displacementScale: 0, + transparent: false, + flipY: true, + bumpScale: 1, + emissiveColor: '#000000', + aoMapIntensity: 1, + side: 0, + opacity: 1, + lightMapIntensity: 1, + }, + }, + }, + { + id: 'roof-terracottatiles', + label: 'Terracotta Tiles', + category: 'roof', + description: 'Terracotta roof tile finish', + previewThumbnailUrl: + '/material/roofing/roof_tiles_terracotta/roof_tiles_terracotta_basecolor.webp', + preset: { + maps: { + albedoMap: '/material/roofing/roof_tiles_terracotta/roof_tiles_terracotta_basecolor.webp', + aoMap: + '/material/roofing/roof_tiles_terracotta/roof_tiles_terracotta_ambientocclusion.webp', + metalnessMap: '/material/roofing/roof_tiles_terracotta/roof_tiles_terracotta_metallic.webp', + normalMap: '/material/roofing/roof_tiles_terracotta/roof_tiles_terracotta_normal.webp', + roughnessMap: + '/material/roofing/roof_tiles_terracotta/roof_tiles_terracotta_roughness.webp', + }, + mapProperties: { + color: '#ffffff', + roughness: 1, + metalness: 0.1, + repeatX: 1, + repeatY: 1, + rotation: 0, + wrapS: 'Repeat', + wrapT: 'Repeat', + normalScaleX: 1, + normalScaleY: 1, + emissiveIntensity: 1, + displacementScale: 0, + transparent: false, + flipY: true, + bumpScale: 1, + emissiveColor: '#000000', + aoMapIntensity: 1, + side: 0, + opacity: 1, + lightMapIntensity: 1, + }, + }, + }, + { + id: 'roof-weatheredshingles', + label: 'Weathered Shingles', + category: 'roof', + description: 'Weathered roof shingle finish', + previewThumbnailUrl: + '/material/roofing/roof_shingles_weathered/roof_shingles_weathered_basecolor.webp', + preset: { + maps: { + albedoMap: + '/material/roofing/roof_shingles_weathered/roof_shingles_weathered_basecolor.webp', + aoMap: + '/material/roofing/roof_shingles_weathered/roof_shingles_weathered_ambientocclusion.webp', + metalnessMap: + '/material/roofing/roof_shingles_weathered/roof_shingles_weathered_metallic.webp', + normalMap: '/material/roofing/roof_shingles_weathered/roof_shingles_weathered_normal.webp', + roughnessMap: + '/material/roofing/roof_shingles_weathered/roof_shingles_weathered_roughness.webp', + }, + mapProperties: { + color: '#ffffff', + roughness: 1, + metalness: 0.1, + repeatX: 1, + repeatY: 1, + rotation: 0, + wrapS: 'Repeat', + wrapT: 'Repeat', + normalScaleX: 1, + normalScaleY: 1, + emissiveIntensity: 1, + displacementScale: 0, + transparent: false, + flipY: true, + bumpScale: 1, + emissiveColor: '#000000', + aoMapIntensity: 1, + side: 0, + opacity: 1, + lightMapIntensity: 1, + }, + }, + }, + { + id: 'preset-white', + label: 'White', + category: 'other', + description: 'Clean painted finish', + previewColor: '#ffffff', + preset: { + maps: {}, + mapProperties: { + color: '#ffffff', + roughness: 0.9, + metalness: 0, + repeatX: 1, + repeatY: 1, + rotation: 0, + wrapS: 'Repeat', + wrapT: 'Repeat', + normalScaleX: 1, + normalScaleY: 1, + emissiveIntensity: 1, + displacementScale: 0.02, + transparent: false, + flipY: true, + bumpScale: 1, + emissiveColor: '#000000', + aoMapIntensity: 1, + side: 0, + opacity: 1, + lightMapIntensity: 1, + }, + }, + }, + { + id: 'preset-softwhite', + label: 'Soft White', + category: 'other', + description: 'Warm off-white painted finish', + previewColor: '#f2eee6', + preset: { + maps: {}, + mapProperties: { + color: '#f2eee6', + roughness: 0.9, + metalness: 0, + repeatX: 1, + repeatY: 1, + rotation: 0, + wrapS: 'Repeat', + wrapT: 'Repeat', + normalScaleX: 1, + normalScaleY: 1, + emissiveIntensity: 1, + displacementScale: 0.02, + transparent: false, + flipY: true, + bumpScale: 1, + emissiveColor: '#000000', + aoMapIntensity: 1, + side: 0, + opacity: 1, + lightMapIntensity: 1, + }, + }, + }, + { + id: 'preset-cream', + label: 'Cream', + category: 'other', + description: 'Soft cream painted finish', + previewColor: '#efe3cc', + preset: { + maps: {}, + mapProperties: { + color: '#efe3cc', + roughness: 0.9, + metalness: 0, + repeatX: 1, + repeatY: 1, + rotation: 0, + wrapS: 'Repeat', + wrapT: 'Repeat', + normalScaleX: 1, + normalScaleY: 1, + emissiveIntensity: 1, + displacementScale: 0.02, + transparent: false, + flipY: true, + bumpScale: 1, + emissiveColor: '#000000', + aoMapIntensity: 1, + side: 0, + opacity: 1, + lightMapIntensity: 1, + }, + }, + }, + { + id: 'preset-beige', + label: 'Beige', + category: 'other', + description: 'Balanced beige painted finish', + previewColor: '#d9c7ad', + preset: { + maps: {}, + mapProperties: { + color: '#d9c7ad', + roughness: 0.9, + metalness: 0, + repeatX: 1, + repeatY: 1, + rotation: 0, + wrapS: 'Repeat', + wrapT: 'Repeat', + normalScaleX: 1, + normalScaleY: 1, + emissiveIntensity: 1, + displacementScale: 0.02, + transparent: false, + flipY: true, + bumpScale: 1, + emissiveColor: '#000000', + aoMapIntensity: 1, + side: 0, + opacity: 1, + lightMapIntensity: 1, + }, + }, + }, + { + id: 'preset-greige', + label: 'Greige', + category: 'other', + description: 'Neutral greige painted finish', + previewColor: '#c8c1b8', + preset: { + maps: {}, + mapProperties: { + color: '#c8c1b8', + roughness: 0.9, + metalness: 0, + repeatX: 1, + repeatY: 1, + rotation: 0, + wrapS: 'Repeat', + wrapT: 'Repeat', + normalScaleX: 1, + normalScaleY: 1, + emissiveIntensity: 1, + displacementScale: 0.02, + transparent: false, + flipY: true, + bumpScale: 1, + emissiveColor: '#000000', + aoMapIntensity: 1, + side: 0, + opacity: 1, + lightMapIntensity: 1, + }, + }, + }, + { + id: 'preset-sage', + label: 'Sage', + category: 'other', + description: 'Muted sage painted finish', + previewColor: '#bcc5b2', + preset: { + maps: {}, + mapProperties: { + color: '#bcc5b2', + roughness: 0.9, + metalness: 0, + repeatX: 1, + repeatY: 1, + rotation: 0, + wrapS: 'Repeat', + wrapT: 'Repeat', + normalScaleX: 1, + normalScaleY: 1, + emissiveIntensity: 1, + displacementScale: 0.02, + transparent: false, + flipY: true, + bumpScale: 1, + emissiveColor: '#000000', + aoMapIntensity: 1, + side: 0, + opacity: 1, + lightMapIntensity: 1, + }, + }, + }, + { + id: 'preset-softblue', + label: 'Soft Blue', + category: 'other', + description: 'Muted blue painted finish', + previewColor: '#c7d6e3', + preset: { + maps: {}, + mapProperties: { + color: '#c7d6e3', + roughness: 0.9, + metalness: 0, + repeatX: 1, + repeatY: 1, + rotation: 0, + wrapS: 'Repeat', + wrapT: 'Repeat', + normalScaleX: 1, + normalScaleY: 1, + emissiveIntensity: 1, + displacementScale: 0.02, + transparent: false, + flipY: true, + bumpScale: 1, + emissiveColor: '#000000', + aoMapIntensity: 1, + side: 0, + opacity: 1, + lightMapIntensity: 1, + }, + }, + }, + { + id: 'preset-terracotta', + label: 'Terracotta', + category: 'other', + description: 'Warm terracotta painted finish', + previewColor: '#c86f4c', + preset: { + maps: {}, + mapProperties: { + color: '#c86f4c', + roughness: 0.9, + metalness: 0, + repeatX: 1, + repeatY: 1, + rotation: 0, + wrapS: 'Repeat', + wrapT: 'Repeat', + normalScaleX: 1, + normalScaleY: 1, + emissiveIntensity: 1, + displacementScale: 0.02, + transparent: false, + flipY: true, + bumpScale: 1, + emissiveColor: '#000000', + aoMapIntensity: 1, + side: 0, + opacity: 1, + lightMapIntensity: 1, + }, + }, + }, + { + id: 'preset-dustyrose', + label: 'Dusty Rose', + category: 'other', + description: 'Muted rose painted finish', + previewColor: '#c48a8d', + preset: { + maps: {}, + mapProperties: { + color: '#c48a8d', + roughness: 0.9, + metalness: 0, + repeatX: 1, + repeatY: 1, + rotation: 0, + wrapS: 'Repeat', + wrapT: 'Repeat', + normalScaleX: 1, + normalScaleY: 1, + emissiveIntensity: 1, + displacementScale: 0.02, + transparent: false, + flipY: true, + bumpScale: 1, + emissiveColor: '#000000', + aoMapIntensity: 1, + side: 0, + opacity: 1, + lightMapIntensity: 1, + }, + }, + }, + { + id: 'preset-olive', + label: 'Olive', + category: 'other', + description: 'Natural olive painted finish', + previewColor: '#8d9368', + preset: { + maps: {}, + mapProperties: { + color: '#8d9368', + roughness: 0.9, + metalness: 0, + repeatX: 1, + repeatY: 1, + rotation: 0, + wrapS: 'Repeat', + wrapT: 'Repeat', + normalScaleX: 1, + normalScaleY: 1, + emissiveIntensity: 1, + displacementScale: 0.02, + transparent: false, + flipY: true, + bumpScale: 1, + emissiveColor: '#000000', + aoMapIntensity: 1, + side: 0, + opacity: 1, + lightMapIntensity: 1, + }, + }, + }, + { + id: 'preset-forest', + label: 'Forest', + category: 'other', + description: 'Deep forest green painted finish', + previewColor: '#4f6b57', + preset: { + maps: {}, + mapProperties: { + color: '#4f6b57', + roughness: 0.9, + metalness: 0, + repeatX: 1, + repeatY: 1, + rotation: 0, + wrapS: 'Repeat', + wrapT: 'Repeat', + normalScaleX: 1, + normalScaleY: 1, + emissiveIntensity: 1, + displacementScale: 0.02, + transparent: false, + flipY: true, + bumpScale: 1, + emissiveColor: '#000000', + aoMapIntensity: 1, + side: 0, + opacity: 1, + lightMapIntensity: 1, + }, + }, + }, + { + id: 'preset-slateblue', + label: 'Slate Blue', + category: 'other', + description: 'Muted slate blue painted finish', + previewColor: '#6f87a4', + preset: { + maps: {}, + mapProperties: { + color: '#6f87a4', + roughness: 0.9, + metalness: 0, + repeatX: 1, + repeatY: 1, + rotation: 0, + wrapS: 'Repeat', + wrapT: 'Repeat', + normalScaleX: 1, + normalScaleY: 1, + emissiveIntensity: 1, + displacementScale: 0.02, + transparent: false, + flipY: true, + bumpScale: 1, + emissiveColor: '#000000', + aoMapIntensity: 1, + side: 0, + opacity: 1, + lightMapIntensity: 1, + }, + }, + }, + { + id: 'preset-navy', + label: 'Navy', + category: 'other', + description: 'Classic navy painted finish', + previewColor: '#2f4865', + preset: { + maps: {}, + mapProperties: { + color: '#2f4865', + roughness: 0.9, + metalness: 0, + repeatX: 1, + repeatY: 1, + rotation: 0, + wrapS: 'Repeat', + wrapT: 'Repeat', + normalScaleX: 1, + normalScaleY: 1, + emissiveIntensity: 1, + displacementScale: 0.02, + transparent: false, + flipY: true, + bumpScale: 1, + emissiveColor: '#000000', + aoMapIntensity: 1, + side: 0, + opacity: 1, + lightMapIntensity: 1, + }, + }, + }, + { + id: 'preset-mustard', + label: 'Mustard', + category: 'other', + description: 'Warm mustard painted finish', + previewColor: '#c8a449', + preset: { + maps: {}, + mapProperties: { + color: '#c8a449', + roughness: 0.9, + metalness: 0, + repeatX: 1, + repeatY: 1, + rotation: 0, + wrapS: 'Repeat', + wrapT: 'Repeat', + normalScaleX: 1, + normalScaleY: 1, + emissiveIntensity: 1, + displacementScale: 0.02, + transparent: false, + flipY: true, + bumpScale: 1, + emissiveColor: '#000000', + aoMapIntensity: 1, + side: 0, + opacity: 1, + lightMapIntensity: 1, + }, + }, + }, + { + id: 'preset-charcoal', + label: 'Charcoal', + category: 'other', + description: 'Dark charcoal painted finish', + previewColor: '#4e5257', + preset: { + maps: {}, + mapProperties: { + color: '#4e5257', roughness: 0.9, metalness: 0, repeatX: 1, diff --git a/packages/core/src/store/use-interactive.ts b/packages/core/src/store/use-interactive.ts index 22997afc0..426e42d71 100644 --- a/packages/core/src/store/use-interactive.ts +++ b/packages/core/src/store/use-interactive.ts @@ -49,6 +49,7 @@ export type ElevatorInteractiveState = { phase: ElevatorPhase phaseStartedAt: number | null queue: AnyNodeId[] + requestedStops: AnyNodeId[] } type InteractiveStore = { @@ -241,6 +242,7 @@ export const useInteractive = create((set, get) => ({ phase: 'idle', phaseStartedAt: null, queue: [], + requestedStops: [], }, }, })) diff --git a/packages/core/src/systems/elevator/elevator-geometry.ts b/packages/core/src/systems/elevator/elevator-geometry.ts index c08b296bf..ad7fe62cd 100644 --- a/packages/core/src/systems/elevator/elevator-geometry.ts +++ b/packages/core/src/systems/elevator/elevator-geometry.ts @@ -73,17 +73,11 @@ export function getElevatorShaftWallThickness(node: ElevatorNode) { return Math.max(node.shaftWallThickness ?? DEFAULT_ELEVATOR_SHAFT_WALL_THICKNESS, 0.04) } -export function getElevatorShaftWidth( - node: ElevatorNode, - cabWidth = getElevatorCabWidth(node), -) { +export function getElevatorShaftWidth(node: ElevatorNode, cabWidth = getElevatorCabWidth(node)) { return Math.max(node.shaftWidth ?? cabWidth, cabWidth, 0.8) } -export function getElevatorShaftDepth( - node: ElevatorNode, - cabDepth = getElevatorCabDepth(node), -) { +export function getElevatorShaftDepth(node: ElevatorNode, cabDepth = getElevatorCabDepth(node)) { return Math.max(node.shaftDepth ?? cabDepth, cabDepth, 0.8) } diff --git a/packages/core/src/systems/elevator/elevator-runtime.test.ts b/packages/core/src/systems/elevator/elevator-runtime.test.ts index 959dd681a..43e714df4 100644 --- a/packages/core/src/systems/elevator/elevator-runtime.test.ts +++ b/packages/core/src/systems/elevator/elevator-runtime.test.ts @@ -30,7 +30,9 @@ describe('elevator runtime helpers', () => { const duplicated = queueElevatorRequest(queued, upperLevelId) expect(queued.queue).toEqual([upperLevelId]) + expect(queued.requestedStops).toEqual([upperLevelId]) expect(duplicated.queue).toEqual([upperLevelId]) + expect(duplicated.requestedStops).toEqual([upperLevelId]) }) test('opens doors only when the elevator is not moving', () => { @@ -42,7 +44,10 @@ describe('elevator runtime helpers', () => { }) test('moves to a queued level and clears the served request on arrival', () => { - const queued = queueElevatorRequest(createElevatorInteractiveState(groundLevelId, 0), upperLevelId) + const queued = queueElevatorRequest( + createElevatorInteractiveState(groundLevelId, 0), + upperLevelId, + ) const moving = stepElevatorRuntimeState({ defaultEntry: entries[0]!, delta: 0.016, @@ -75,5 +80,6 @@ describe('elevator runtime helpers', () => { expect(arrived.phase).toBe('opening') expect(open.phase).toBe('open') expect(open.queue).toEqual([]) + expect(open.requestedStops).toEqual([upperLevelId]) }) }) diff --git a/packages/core/src/systems/elevator/elevator-runtime.ts b/packages/core/src/systems/elevator/elevator-runtime.ts index c75a30bc9..7278dea0d 100644 --- a/packages/core/src/systems/elevator/elevator-runtime.ts +++ b/packages/core/src/systems/elevator/elevator-runtime.ts @@ -1,7 +1,7 @@ import type { AnyNode, AnyNodeId, ElevatorNode } from '../../schema' import { type ElevatorInteractiveState, useInteractive } from '../../store/use-interactive' import useScene from '../../store/use-scene' -import { resolveElevatorLevels, type ElevatorLevelEntry } from './elevator-service' +import { type ElevatorLevelEntry, resolveElevatorLevels } from './elevator-service' const EPSILON = 0.001 @@ -23,6 +23,7 @@ export function createElevatorInteractiveState( phase: 'idle', phaseStartedAt: null, queue: [], + requestedStops: [], } } @@ -64,12 +65,13 @@ export function queueElevatorRequest( return { ...state, queue: [...state.queue, levelId], + requestedStops: state.requestedStops.includes(levelId) + ? state.requestedStops + : [...state.requestedStops, levelId], } } -export function openElevatorDoorState( - state: ElevatorInteractiveState, -): ElevatorInteractiveState { +export function openElevatorDoorState(state: ElevatorInteractiveState): ElevatorInteractiveState { if (!state.currentLevelId || state.phase === 'moving') return state return { @@ -126,6 +128,7 @@ export function stepElevatorRuntimeState({ phase: 'idle', phaseStartedAt: null, queue: [], + requestedStops: [], doorOpen: 0, } } @@ -149,7 +152,11 @@ export function stepElevatorRuntimeState({ doorOpen: Math.max(0, state.doorOpen - doorStep), } } - return state + if (state.requestedStops.length === 0) return state + return { + ...state, + requestedStops: [], + } } return { @@ -182,6 +189,7 @@ export function stepElevatorRuntimeState({ targetLevelId: null, phase: 'idle', queue: [], + requestedStops: [], } } @@ -246,7 +254,9 @@ export function stepElevatorRuntimes(now: number, delta: number) { const state = useInteractive.getState().elevators[elevatorId] if (!state) { - useInteractive.getState().initElevator(elevatorId, defaultEntry.id as AnyNodeId, defaultEntry.baseY) + useInteractive + .getState() + .initElevator(elevatorId, defaultEntry.id as AnyNodeId, defaultEntry.baseY) continue } diff --git a/packages/core/src/systems/elevator/elevator-service.ts b/packages/core/src/systems/elevator/elevator-service.ts index 59798da5d..5239434cf 100644 --- a/packages/core/src/systems/elevator/elevator-service.ts +++ b/packages/core/src/systems/elevator/elevator-service.ts @@ -1,4 +1,11 @@ -import type { AnyNode, AnyNodeId, CeilingNode, ElevatorNode, LevelNode, WallNode } from '../../schema' +import type { + AnyNode, + AnyNodeId, + CeilingNode, + ElevatorNode, + LevelNode, + WallNode, +} from '../../schema' export const DEFAULT_ELEVATOR_LEVEL_HEIGHT = 2.5 diff --git a/packages/core/src/systems/stair/stair-opening-sync.ts b/packages/core/src/systems/stair/stair-opening-sync.ts index 6f4c9f7ae..5c9b296f2 100644 --- a/packages/core/src/systems/stair/stair-opening-sync.ts +++ b/packages/core/src/systems/stair/stair-opening-sync.ts @@ -74,34 +74,7 @@ function normalizeExistingMetadata( return holes.map((_, index) => metadata?.[index] ?? { source: 'manual' }) } -function expandPolygonFromCentroid(polygon: Point2D[], offset: number) { - if (Math.abs(offset) < 1e-6) { - return polygon.map(([x, z]) => [x, z] as Point2D) - } - - const centroid = polygon.reduce( - (acc, [x, z]) => { - acc.x += x - acc.z += z - return acc - }, - { x: 0, z: 0 }, - ) - centroid.x /= Math.max(polygon.length, 1) - centroid.z /= Math.max(polygon.length, 1) - - return polygon.map(([x, z]) => { - const dx = x - centroid.x - const dz = z - centroid.z - const length = Math.hypot(dx, dz) - if (length < 1e-6) { - return [x, z] as Point2D - } - - const scale = Math.max(0.1, (length + offset) / length) - return [centroid.x + dx * scale, centroid.z + dz * scale] as Point2D - }) -} +// (Removing expandPolygonRadially in favor of geometric expansion inside the polygon generators) function rotateXZ(x: number, z: number, angle: number): [number, number] { const cos = Math.cos(angle) @@ -266,7 +239,7 @@ function getStraightFlightOpeningDepth(stair: StairNode, segment: StairSegmentNo 0.2, segment.length / Math.max(segment.stepCount || stair.stepCount || 10, 1), ) - return Math.min(segment.length, Math.max(treadDepth * 6, segment.length * 0.62, 1.8)) + return Math.min(segment.length, Math.max(treadDepth * 10, segment.length * 0.8, 3.0)) } function polygonArea(points: Point2D[]) { @@ -424,17 +397,18 @@ function buildUnionPolygonsFromRects(rects: AxisAlignedRect[]): Point2D[][] { return polygons } -function getCurvedOpeningPolygon(stair: StairNode): Point2D[] { +function getCurvedOpeningPolygon(stair: StairNode, offset: number = 0): Point2D[] { const width = Math.max(stair.width ?? 1, 0.4) - const innerRadius = Math.max(0.2, stair.innerRadius ?? 0.9) - const outerRadius = innerRadius + width + const innerRadius = Math.max(0.01, (stair.innerRadius ?? 0.9) - offset) + const outerRadius = (stair.innerRadius ?? 0.9) + width + offset const totalSweep = stair.sweepAngle ?? Math.PI / 2 + const baseOpeningSweep = + Math.abs(totalSweep) * + Math.max(CURVED_STAIR_SLAB_OPENING_RATIO, 1 / Math.max(stair.stepCount ?? 1, 1)) + const angleOffset = offset / Math.max(innerRadius, 0.1) const openingSweep = - Math.sign(totalSweep || 1) * - Math.max( - Math.abs(totalSweep) * CURVED_STAIR_SLAB_OPENING_RATIO, - Math.abs(totalSweep) / Math.max(stair.stepCount ?? 1, 1), - ) + Math.sign(totalSweep || 1) * Math.min(Math.abs(totalSweep), baseOpeningSweep + angleOffset * 2) + const startAngle = totalSweep / 2 - openingSweep const endAngle = totalSweep / 2 const segmentCount = Math.max( @@ -466,8 +440,8 @@ function getCurvedOpeningPolygon(stair: StairNode): Point2D[] { return [...outerPoints, ...innerPoints] } -function getSpiralOpeningPolygon(stair: StairNode): Point2D[] { - const radius = Math.max(0.05, stair.innerRadius ?? 0.9) + Math.max(stair.width ?? 1, 0.4) +function getSpiralOpeningPolygon(stair: StairNode, offset: number = 0): Point2D[] { + const radius = Math.max(0.05, stair.innerRadius ?? 0.9) + Math.max(stair.width ?? 1, 0.4) + offset const segmentCount = 48 return Array.from({ length: segmentCount }).map((_, index) => { @@ -476,6 +450,22 @@ function getSpiralOpeningPolygon(stair: StairNode): Point2D[] { }) } +function getSpiralLandingPolygon(stair: StairNode, offset: number = 0): Point2D[] { + const width = Math.max(stair.width ?? 1, 0.4) + const outerRadius = Math.max(0.05, (stair.innerRadius ?? 0.9) + width) + const depth = Math.max(stair.topLandingDepth ?? 0.9, 0.1) + const halfWidth = width / 2 + + const localPoints: Point2D[] = [ + [outerRadius - offset, -halfWidth - offset], + [outerRadius + depth + offset, -halfWidth - offset], + [outerRadius + depth + offset, halfWidth + offset], + [outerRadius - offset, halfWidth + offset], + ] + + return localPoints.map(([x, z]) => toWorldPlanPoint(stair, x, z)) +} + function getStraightOpeningPolygonsForSurface( stair: StairNode, nodes: Record, @@ -486,7 +476,7 @@ function getStraightOpeningPolygonsForSurface( const riserHeight = (stair.totalRise ?? 2.5) / Math.max(stair.stepCount ?? 10, 1) const targetThreshold = Math.max(riserHeight * 2, STRAIGHT_STAIR_TARGET_THRESHOLD_MIN) - const openingOffset = Math.max(stair.openingOffset ?? 0, 0) + const openingOffset = Math.max(stair.openingOffset ?? 0, 0.15) const openingRects: AxisAlignedRect[] = [] for (let index = 0; index < layouts.length; index += 1) { @@ -570,11 +560,21 @@ function getStairOpeningPolygons( } if (stair.stairType === 'curved') { - return [getCurvedOpeningPolygon(stair)] + return [ + getCurvedOpeningPolygon( + stair, + Math.max((stair.openingOffset ?? 0) - STAIR_SLAB_OPENING_TIGHTENING, 0.15), + ), + ] } if (stair.stairType === 'spiral') { - return [getSpiralOpeningPolygon(stair)] + const offset = Math.max((stair.openingOffset ?? 0) - STAIR_SLAB_OPENING_TIGHTENING, 0.15) + const polygons = [getSpiralOpeningPolygon(stair, offset)] + if (stair.topLandingMode === 'integrated') { + polygons.push(getSpiralLandingPolygon(stair, offset)) + } + return polygons } if (typeof targetElevation === 'number') { @@ -703,13 +703,7 @@ export function syncAutoStairOpenings(nodes: Record) { nodes, getTargetSlabElevationForStair(stair, slab, slabLevelId, nodes), ).map((polygon) => ({ - polygon: - stair.stairType === 'straight' - ? polygon - : expandPolygonFromCentroid( - polygon, - Math.max((stair.openingOffset ?? 0) - STAIR_SLAB_OPENING_TIGHTENING, 0), - ), + polygon, metadata: { source: 'stair' as const, stairId: stair.id, @@ -758,13 +752,7 @@ export function syncAutoStairOpenings(nodes: Record) { nodes, getTargetCeilingElevationForStair(stair, ceiling, ceilingLevelId, nodes), ).map((polygon) => ({ - polygon: - stair.stairType === 'straight' - ? polygon - : expandPolygonFromCentroid( - polygon, - Math.max((stair.openingOffset ?? 0) - STAIR_SLAB_OPENING_TIGHTENING, 0), - ), + polygon, metadata: { source: 'stair' as const, stairId: stair.id, diff --git a/packages/core/src/systems/stair/stair-opening-system.tsx b/packages/core/src/systems/stair/stair-opening-system.tsx new file mode 100644 index 000000000..2558fcc5a --- /dev/null +++ b/packages/core/src/systems/stair/stair-opening-system.tsx @@ -0,0 +1,59 @@ +'use client' + +import { useEffect, useRef } from 'react' +import type { AnyNode } from '../../schema' +import useScene from '../../store/use-scene' +import { syncAutoStairOpenings } from './stair-opening-sync' + +function isOpeningRelevantNode(node: AnyNode | undefined) { + return ( + node?.type === 'building' || + node?.type === 'ceiling' || + node?.type === 'level' || + node?.type === 'slab' || + node?.type === 'stair' || + node?.type === 'stair-segment' + ) +} + +function hasOpeningRelevantNodeChange( + nextNodes: Record, + prevNodes: Record, +) { + if (nextNodes === prevNodes) return false + + const ids = new Set([...Object.keys(nextNodes), ...Object.keys(prevNodes)]) + for (const id of ids) { + const nextNode = nextNodes[id] + const prevNode = prevNodes[id] + if (nextNode === prevNode) continue + if (isOpeningRelevantNode(nextNode) || isOpeningRelevantNode(prevNode)) return true + } + + return false +} + +export const StairOpeningSystem = () => { + const syncingAutoOpeningsRef = useRef(false) + + useEffect(() => { + const applyUpdates = (updates: ReturnType) => { + if (updates.length === 0) return + syncingAutoOpeningsRef.current = true + useScene.getState().updateNodes(updates) + queueMicrotask(() => { + syncingAutoOpeningsRef.current = false + }) + } + + applyUpdates(syncAutoStairOpenings(useScene.getState().nodes)) + + return useScene.subscribe((state, prevState) => { + if (syncingAutoOpeningsRef.current) return + if (!hasOpeningRelevantNodeChange(state.nodes, prevState.nodes)) return + applyUpdates(syncAutoStairOpenings(state.nodes)) + }) + }, []) + + return null +} diff --git a/packages/core/src/systems/wall/wall-move.ts b/packages/core/src/systems/wall/wall-move.ts index e9b6595c4..15fffd9d9 100644 --- a/packages/core/src/systems/wall/wall-move.ts +++ b/packages/core/src/systems/wall/wall-move.ts @@ -3,7 +3,8 @@ import type { WallNode } from '../../schema' const AXIS_EPSILON = 1e-6 export type WallPlanPoint = [number, number] -export type WallMoveAxis = 'x' | 'z' +// Unit direction vector (x,z) to constrain move deltas along. +export type WallMoveAxis = [number, number] export type WallMoveEndpoint = 'start' | 'end' export type WallMoveBridgePlan> = { @@ -12,9 +13,7 @@ export type WallMoveBridgePlan, -> = { +export type WallMoveLinkedWallTargetPlan> = { wall: TWall originalPoint: WallPlanPoint targetPoint: WallPlanPoint @@ -31,12 +30,15 @@ export function getPerpendicularWallMoveAxis( start: WallPlanPoint, end: WallPlanPoint, ): WallMoveAxis | null { - const wallDeltaX = Math.abs(end[0] - start[0]) - const wallDeltaZ = Math.abs(end[1] - start[1]) + const dx = end[0] - start[0] + const dz = end[1] - start[1] + const length = Math.hypot(dx, dz) - if (wallDeltaX < AXIS_EPSILON && wallDeltaZ < AXIS_EPSILON) return null + if (length < AXIS_EPSILON) return null - return wallDeltaX >= wallDeltaZ ? 'z' : 'x' + // Perpendicular (normal) direction for moving the wall "sideways". + // This matches the arrow handles shown in the editor. + return [-dz / length, dx / length] } export function constrainWallMoveDeltaToAxis( @@ -44,8 +46,10 @@ export function constrainWallMoveDeltaToAxis( deltaZ: number, axis: WallMoveAxis | null, ): WallPlanPoint { - if (axis === 'x') return [deltaX, 0] - if (axis === 'z') return [0, deltaZ] + if (axis) { + const projected = deltaX * axis[0] + deltaZ * axis[1] + return [axis[0] * projected, axis[1] * projected] + } return [deltaX, deltaZ] } @@ -174,7 +178,9 @@ export function planWallMoveJunctions a.distance - b.distance)[0] if (consumedSameDirectionWall) { - const pivotPoint = [...otherWallEndpoint(consumedSameDirectionWall.wall, point)] as WallPlanPoint + const pivotPoint = [ + ...otherWallEndpoint(consumedSameDirectionWall.wall, point), + ] as WallPlanPoint const bridgeSource = linkedAtEndpoint.find((entry) => entry.relation === 'opposite-direction') wallsToDelete.set(consumedSameDirectionWall.wall.id, consumedSameDirectionWall.wall) @@ -201,14 +207,22 @@ export function planWallMoveJunctions wall.id !== consumedSameDirectionWall.wall.id && wallTouchesPoint(wall, pivotPoint), + (wall) => + wall.id !== consumedSameDirectionWall.wall.id && wallTouchesPoint(wall, pivotPoint), ) .map((wall) => ({ wall, relation: getMoveWallRelation(wall, pivotPoint, nextPoint), })) - addStandardEndpointPlan(endpoint, pivotPoint, nextPoint, linkedAtPivot, ':through-pivot', true) + addStandardEndpointPlan( + endpoint, + pivotPoint, + nextPoint, + linkedAtPivot, + ':through-pivot', + true, + ) return } diff --git a/packages/editor/src/components/editor-2d/floorplan-action-menu-layer.tsx b/packages/editor/src/components/editor-2d/floorplan-action-menu-layer.tsx index 97e64e755..89bd2b6da 100644 --- a/packages/editor/src/components/editor-2d/floorplan-action-menu-layer.tsx +++ b/packages/editor/src/components/editor-2d/floorplan-action-menu-layer.tsx @@ -14,7 +14,7 @@ export type FloorplanActionMenuHandler = (event: ReactMouseEvent + ) : ( 90) { + normalized -= 180 + } else if (normalized <= -90) { + normalized += 180 + } + + return normalized } export const FloorplanMeasurementsLayer = memo(function FloorplanMeasurementsLayer({ className, measurements, palette, + sceneRotationDeg = 0, }: FloorplanMeasurementsLayerProps) { if (measurements.length === 0) { return null @@ -124,79 +155,90 @@ export const FloorplanMeasurementsLayer = memo(function FloorplanMeasurementsLay return ( <> - {measurements.map((measurement) => ( - - - - - - {measurement.showTicks !== false ? ( - <> - + (() => { + const screenLabelAngleDeg = normalizeReadableScreenAngle( + measurement.labelAngleDeg + sceneRotationDeg, + ) + const localLabelAngleDeg = screenLabelAngleDeg - sceneRotationDeg + + return ( + + + - - - ) : null} - - {measurement.label} - - - ))} + + {measurement.showTicks !== false ? ( + <> + + + + ) : null} + + {measurement.label} + + + ) + })(), + )} ) }) diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-stair-layer.tsx b/packages/editor/src/components/editor-2d/renderers/floorplan-stair-layer.tsx index 20ebebbf6..46170e86b 100644 --- a/packages/editor/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +++ b/packages/editor/src/components/editor-2d/renderers/floorplan-stair-layer.tsx @@ -83,6 +83,14 @@ function getNormalizedFloorplanStairSweepAngle(stair: StairNode) { return baseSweepAngle } +function clampFloorplanCircularSweepAngle(sweepAngle: number) { + if (Math.abs(sweepAngle) >= Math.PI * 2) { + return Math.sign(sweepAngle || 1) * (Math.PI * 2 - 0.001) + } + + return sweepAngle +} + function getFloorplanStairStepCount(stair: StairNode, minimum: number) { return Math.max(minimum, Math.round(stair.stepCount ?? 10)) } @@ -139,7 +147,10 @@ export const FloorplanStairLayer = memo(function FloorplanStairLayer({ const sectorStartAngle = -stair.rotation - normalizedSweepAngle / 2 const sectorEndAngle = sectorStartAngle + normalizedSweepAngle const spiralLandingSweep = getFloorplanSpiralLandingSweep(stair, normalizedSweepAngle) - const visualSectorEndAngle = sectorEndAngle + spiralLandingSweep + const visualSweepAngle = clampFloorplanCircularSweepAngle( + normalizedSweepAngle + spiralLandingSweep, + ) + const visualSectorEndAngle = sectorStartAngle + visualSweepAngle const stairCenter = { x: stair.position[0], y: stair.position[2], diff --git a/packages/editor/src/components/editor/custom-camera-controls.tsx b/packages/editor/src/components/editor/custom-camera-controls.tsx index 7392da7b0..5050ee4a0 100644 --- a/packages/editor/src/components/editor/custom-camera-controls.tsx +++ b/packages/editor/src/components/editor/custom-camera-controls.tsx @@ -7,7 +7,7 @@ import { sceneRegistry, useScene, } from '@pascal-app/core' -import { useViewer, WalkthroughControls, ZONE_LAYER } from '@pascal-app/viewer' +import { useViewer, ZONE_LAYER } from '@pascal-app/viewer' import { CameraControls, CameraControlsImpl } from '@react-three/drei' import { useThree } from '@react-three/fiber' import { useCallback, useEffect, useMemo, useRef } from 'react' diff --git a/packages/editor/src/components/editor/first-person-controls.tsx b/packages/editor/src/components/editor/first-person-controls.tsx index 2b0074fde..fd04eaeec 100644 --- a/packages/editor/src/components/editor/first-person-controls.tsx +++ b/packages/editor/src/components/editor/first-person-controls.tsx @@ -1,11 +1,10 @@ 'use client' -import '../../three-types' import { type AnyNode, type AnyNodeId, - type ElevatorNode, type ElevatorDoorSide, + type ElevatorNode, emitter, getElevatorCabCenterZ, getElevatorCabDepth, @@ -18,10 +17,10 @@ import { getElevatorShaftWidth, getResolvedElevatorDoorStyle, openElevatorDoor, + requestElevatorLevel, resolveElevatorBuildingLevels, resolveElevatorDispatchTarget, resolveElevatorServiceLevels, - requestElevatorLevel, sceneRegistry, useInteractive, useScene, @@ -45,6 +44,7 @@ import { Vector3, } from 'three' import { acceleratedRaycast, computeBoundsTree, disposeBoundsTree } from 'three-mesh-bvh' +import '../../three-types' import { closeDoorOpenState, DOOR_SWING_OPEN_ANGLE, @@ -1324,7 +1324,7 @@ export const FirstPersonOverlay = ({ onExit }: { onExit: () => void }) => { return ( <> {isLocked && ( -
+
@@ -1332,7 +1332,7 @@ export const FirstPersonOverlay = ({ onExit }: { onExit: () => void }) => {
)} -
+
{!hasPlacedSpawn && ( -
+
Place a Spawn Point from the Build tab to control where walkthrough starts.
@@ -1354,7 +1354,7 @@ export const FirstPersonOverlay = ({ onExit }: { onExit: () => void }) => { )} {isLocked && ( -
+
diff --git a/packages/editor/src/components/editor/floating-action-menu.tsx b/packages/editor/src/components/editor/floating-action-menu.tsx index b9fb6bdaa..7af407ddb 100644 --- a/packages/editor/src/components/editor/floating-action-menu.tsx +++ b/packages/editor/src/components/editor/floating-action-menu.tsx @@ -445,7 +445,10 @@ export function FloatingActionMenu() { : undefined } onMove={ - node && node.type !== 'wall' && !DELETE_ONLY_TYPES.includes(node.type) + node && + node.type !== 'wall' && + node.type !== 'fence' && + !DELETE_ONLY_TYPES.includes(node.type) ? handleMove : undefined } diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index 6ceb95068..11b85c2fc 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -12,6 +12,7 @@ import { type ElevatorNode, emitter, type FenceNode, + FenceNode as FenceNodeSchema, type GridEvent, type GuideNode, getRenderableSlabPolygon, @@ -43,8 +44,8 @@ import { useLiveNodeOverrides, useLiveTransforms, useScene, - WallNode as WallNodeSchema, type WallNode, + WallNode as WallNodeSchema, WindowNode, ZoneNode as ZoneNodeSchema, type ZoneNode as ZoneNodeType, @@ -92,9 +93,21 @@ import { } from '../editor-2d/renderers/floorplan-measurements-layer' import { FloorplanRoofLayer } from '../editor-2d/renderers/floorplan-roof-layer' import { FloorplanStairLayer } from '../editor-2d/renderers/floorplan-stair-layer' -import { buildSvgPolylinePath, formatPolygonPath, getArcPlanPoint } from '../editor-2d/svg-paths' +import { + buildSvgArcPath, + buildSvgArrowHeadPoints, + buildSvgPolylinePath, + formatPolygonPath, + getArcPlanPoint, +} from '../editor-2d/svg-paths' import { snapFenceDraftPoint } from '../tools/fence/fence-drafting' import { snapToHalf } from '../tools/item/placement-math' +import { + formatAngleRadians, + getAngleArcToSegmentReference, + getAngleToSegmentReference, + getSegmentAngleReferenceAtPoint, +} from '../tools/shared/segment-angle' import { DEFAULT_STAIR_ATTACHMENT_SIDE, DEFAULT_STAIR_FILL_TO_FLOOR, @@ -151,6 +164,14 @@ const FLOORPLAN_ENDPOINT_HANDLE_ACTIVE_RADIUS_PX = 9 const FLOORPLAN_ENDPOINT_HANDLE_DOT_RADIUS_PX = 3 const FLOORPLAN_ENDPOINT_HANDLE_ACTIVE_DOT_RADIUS_PX = 4 const FLOORPLAN_CURVE_HANDLE_DOT_RADIUS_PX = 3 +const FLOORPLAN_WALL_MOVE_ARROW_OFFSET = 0.28 +const FLOORPLAN_WALL_MOVE_ARROW_MIN_OFFSET = 0.5 +const FLOORPLAN_WALL_MOVE_ARROW_BODY_LENGTH = 0.14 +const FLOORPLAN_WALL_MOVE_ARROW_BODY_WIDTH = 0.06 +const FLOORPLAN_WALL_MOVE_ARROW_HEAD_LENGTH = 0.14 +const FLOORPLAN_WALL_MOVE_ARROW_HIT_RADIUS = 0.18 +const FLOORPLAN_WALL_MOVE_ARROW_COLOR = '#8381ed' +const FLOORPLAN_WALL_MOVE_ARROW_HOVER_COLOR = '#a5b4fc' const FLOORPLAN_POLYGON_VERTEX_RADIUS_PX = 6.5 const FLOORPLAN_POLYGON_VERTEX_ACTIVE_RADIUS_PX = 7.5 const FLOORPLAN_POLYGON_VERTEX_DOT_RADIUS_PX = 2.5 @@ -188,14 +209,18 @@ const FLOORPLAN_SLAB_LABEL_FONT_SIZE = 0.2 const FLOORPLAN_MEASUREMENT_LABEL_STROKE_WIDTH = 0 const FLOORPLAN_MEASUREMENT_LABEL_GAP = 0.56 const FLOORPLAN_MEASUREMENT_LABEL_LINE_PADDING = 0.14 -const FLOORPLAN_WALL_OUTER_MEASUREMENT_OFFSET = 0.34 -const FLOORPLAN_WALL_INNER_MEASUREMENT_OFFSET = 0.24 +const FLOORPLAN_WALL_OUTER_MEASUREMENT_OFFSET = 0.18 +const FLOORPLAN_WALL_INNER_MEASUREMENT_OFFSET = 0.18 const FLOORPLAN_WALL_OUTER_MEASUREMENT_STROKE = 'rgba(59, 130, 246, 0.95)' const FLOORPLAN_WALL_OUTER_MEASUREMENT_TEXT = 'rgba(37, 99, 235, 0.98)' const FLOORPLAN_WALL_OUTER_MEASUREMENT_EXTENSION = 'rgba(96, 165, 250, 0.9)' const FLOORPLAN_WALL_INNER_MEASUREMENT_STROKE = 'rgba(96, 165, 250, 0.95)' const FLOORPLAN_WALL_INNER_MEASUREMENT_TEXT = 'rgba(59, 130, 246, 0.98)' const FLOORPLAN_WALL_INNER_MEASUREMENT_EXTENSION = 'rgba(147, 197, 253, 0.9)' +const FLOORPLAN_DRAFT_ANGLE_ARC_MIN_RADIUS = 0.32 +const FLOORPLAN_DRAFT_ANGLE_ARC_MAX_RADIUS = 0.72 +const FLOORPLAN_DRAFT_ANGLE_LABEL_OFFSET = 0.16 +const FLOORPLAN_DRAFT_ANGLE_STROKE = 'rgba(249, 115, 22, 0.98)' const FLOORPLAN_OPENING_MEASUREMENT_STROKE = 'rgba(249, 115, 22, 0.98)' const FLOORPLAN_OPENING_MEASUREMENT_TEXT = 'rgba(234, 88, 12, 0.98)' const FLOORPLAN_OPENING_MEASUREMENT_EXTENSION = 'rgba(251, 146, 60, 0.9)' @@ -539,6 +564,20 @@ type WallPolygonEntry = { points: string } +type FloorplanWallMoveHandle = { + id: string + point: Point2D + rotationDeg: number + wall: WallNode +} + +type FloorplanFenceMoveHandle = { + id: string + point: Point2D + rotationDeg: number + fence: FenceNode +} + type FloorplanFenceEntry = { fence: FenceNode centerline: Point2D[] @@ -1687,6 +1726,94 @@ function formatPolygonPoints(points: Point2D[]): string { .join(' ') } +function getFloorplanWallMoveHandles(wall: WallNode): FloorplanWallMoveHandle[] { + const dx = wall.end[0] - wall.start[0] + const dy = wall.end[1] - wall.start[1] + const length = Math.hypot(dx, dy) + + if (length < 1e-6) { + return [] + } + + const frame = isCurvedWall(wall) ? getWallCurveFrameAt(wall, 0.5) : null + const midpoint = frame + ? frame.point + : { + x: (wall.start[0] + wall.end[0]) / 2, + y: (wall.start[1] + wall.end[1]) / 2, + } + const normal = frame ? frame.normal : { x: -dy / length, y: dx / length } + const offset = Math.max( + (wall.thickness ?? 0.1) / 2 + FLOORPLAN_WALL_MOVE_ARROW_OFFSET, + FLOORPLAN_WALL_MOVE_ARROW_MIN_OFFSET, + ) + + return [ + { + id: `${wall.id}:move:front`, + point: { + x: midpoint.x + normal.x * offset, + y: midpoint.y + normal.y * offset, + }, + rotationDeg: (Math.atan2(normal.y, normal.x) * 180) / Math.PI, + wall, + }, + { + id: `${wall.id}:move:back`, + point: { + x: midpoint.x - normal.x * offset, + y: midpoint.y - normal.y * offset, + }, + rotationDeg: (Math.atan2(-normal.y, -normal.x) * 180) / Math.PI, + wall, + }, + ] +} + +function getFloorplanFenceMoveHandles(fence: FenceNode): FloorplanFenceMoveHandle[] { + const dx = fence.end[0] - fence.start[0] + const dy = fence.end[1] - fence.start[1] + const length = Math.hypot(dx, dy) + + if (length < 1e-6) { + return [] + } + + const frame = isCurvedWall(fence) ? getWallCurveFrameAt(fence as unknown as WallNode, 0.5) : null + const midpoint = frame + ? frame.point + : { + x: (fence.start[0] + fence.end[0]) / 2, + y: (fence.start[1] + fence.end[1]) / 2, + } + const normal = frame ? frame.normal : { x: -dy / length, y: dx / length } + const offset = Math.max( + (fence.thickness ?? 0.1) / 2 + FLOORPLAN_WALL_MOVE_ARROW_OFFSET, + FLOORPLAN_WALL_MOVE_ARROW_MIN_OFFSET, + ) + + return [ + { + id: `${fence.id}:move:front`, + point: { + x: midpoint.x + normal.x * offset, + y: midpoint.y + normal.y * offset, + }, + rotationDeg: (Math.atan2(normal.y, normal.x) * 180) / Math.PI, + fence, + }, + { + id: `${fence.id}:move:back`, + point: { + x: midpoint.x - normal.x * offset, + y: midpoint.y - normal.y * offset, + }, + rotationDeg: (Math.atan2(-normal.y, -normal.x) * 180) / Math.PI, + fence, + }, + ] +} + function toFloorplanPolygon(points: Array<[number, number]>): Point2D[] { return points.map(([x, y]) => ({ x, y })) } @@ -2022,6 +2149,14 @@ function getNormalizedFloorplanStairSweepAngle(stair: StairNode) { return baseSweepAngle } +function clampFloorplanCircularSweepAngle(sweepAngle: number) { + if (Math.abs(sweepAngle) >= Math.PI * 2) { + return Math.sign(sweepAngle || 1) * (Math.PI * 2 - 0.001) + } + + return sweepAngle +} + function getFloorplanSpiralLandingSweep(stair: StairNode, sweepAngle: number) { if ( (stair.stairType ?? 'straight') !== 'spiral' || @@ -2043,8 +2178,11 @@ function getFloorplanSpiralLandingSweep(stair: StairNode, sweepAngle: number) { function getFloorplanCurvedStairHitPolygon(stair: StairNode): Point2D[] { const stairType = stair.stairType ?? 'straight' const sweepAngle = getNormalizedFloorplanStairSweepAngle(stair) + const visualSweepAngle = clampFloorplanCircularSweepAngle( + sweepAngle + getFloorplanSpiralLandingSweep(stair, sweepAngle), + ) const startAngle = -stair.rotation - sweepAngle / 2 - const endAngle = startAngle + sweepAngle + getFloorplanSpiralLandingSweep(stair, sweepAngle) + const endAngle = startAngle + visualSweepAngle const center = { x: stair.position[0], y: stair.position[2], @@ -2239,6 +2377,68 @@ function buildDraftWall(levelId: string, start: WallPlanPoint, end: WallPlanPoin } } +type DraftWallAngleOverlay = { + id: string + path: string + label: string + labelX: number + labelY: number +} + +function getDraftWallAngleOverlays( + start: WallPlanPoint, + end: WallPlanPoint, + walls: WallNode[], +): DraftWallAngleOverlay[] { + const draftFromStart: WallPlanPoint = [end[0] - start[0], end[1] - start[1]] + const draftFromEnd: WallPlanPoint = [start[0] - end[0], start[1] - end[1]] + const endpoints = [ + { id: 'start', point: start, draftVector: draftFromStart }, + { id: 'end', point: end, draftVector: draftFromEnd }, + ] + const overlays: DraftWallAngleOverlay[] = [] + + for (const endpoint of endpoints) { + const connectedWall = walls.find((wall) => + Boolean(getSegmentAngleReferenceAtPoint(endpoint.point, wall)), + ) + if (!connectedWall) continue + + const connectedReference = getSegmentAngleReferenceAtPoint(endpoint.point, connectedWall) + if (!connectedReference) continue + + const angle = getAngleToSegmentReference(endpoint.draftVector, connectedReference) + const arc = getAngleArcToSegmentReference(endpoint.draftVector, connectedReference) + if (angle === null || !arc || arc.angle < 0.01) continue + + const draftLength = Math.hypot(endpoint.draftVector[0], endpoint.draftVector[1]) + const referenceLength = Math.hypot(connectedReference.vector[0], connectedReference.vector[1]) + const radius = clamp( + Math.min(draftLength, referenceLength) * 0.28, + FLOORPLAN_DRAFT_ANGLE_ARC_MIN_RADIUS, + FLOORPLAN_DRAFT_ANGLE_ARC_MAX_RADIUS, + ) + overlays.push({ + id: endpoint.id, + path: buildSvgArcPath( + { x: endpoint.point[0], y: endpoint.point[1] }, + radius, + arc.startAngle, + arc.endAngle, + ), + label: formatAngleRadians(angle), + labelX: toSvgX( + endpoint.point[0] + Math.cos(arc.midAngle) * (radius + FLOORPLAN_DRAFT_ANGLE_LABEL_OFFSET), + ), + labelY: toSvgY( + endpoint.point[1] + Math.sin(arc.midAngle) * (radius + FLOORPLAN_DRAFT_ANGLE_LABEL_OFFSET), + ), + }) + } + + return overlays +} + function pointsEqual(a: WallPlanPoint, b: WallPlanPoint): boolean { return a[0] === b[0] && a[1] === b[1] } @@ -2687,6 +2887,180 @@ function getLinearMeasurementOverlay( } } +function getPolylineLength(points: Point2D[]) { + let length = 0 + + for (let index = 1; index < points.length; index += 1) { + length += getPlanPointDistance(points[index - 1]!, points[index]!) + } + + return length +} + +function getPolylinePointAtDistance(points: Point2D[], distance: number) { + if (points.length === 0) return null + if (points.length === 1) { + return { + point: points[0]!, + tangent: { x: 1, y: 0 }, + } + } + + let travelled = 0 + for (let index = 1; index < points.length; index += 1) { + const start = points[index - 1]! + const end = points[index]! + const segmentLength = getPlanPointDistance(start, end) + if (segmentLength <= 1e-9) continue + + if (travelled + segmentLength >= distance) { + const t = clamp((distance - travelled) / segmentLength, 0, 1) + return { + point: interpolatePlanPoint(start, end, t), + tangent: { + x: (end.x - start.x) / segmentLength, + y: (end.y - start.y) / segmentLength, + }, + } + } + + travelled += segmentLength + } + + const previous = points[points.length - 2]! + const last = points[points.length - 1]! + const finalLength = getPlanPointDistance(previous, last) + + return { + point: last, + tangent: + finalLength > 1e-9 + ? { + x: (last.x - previous.x) / finalLength, + y: (last.y - previous.y) / finalLength, + } + : { x: 1, y: 0 }, + } +} + +function slicePolylineByDistance(points: Point2D[], startDistance: number, endDistance: number) { + if (points.length < 2 || endDistance <= startDistance) return [] + + const startSample = getPolylinePointAtDistance(points, startDistance) + const endSample = getPolylinePointAtDistance(points, endDistance) + if (!(startSample && endSample)) return [] + + const slicedPoints: Point2D[] = [startSample.point] + let travelled = 0 + + for (let index = 1; index < points.length - 1; index += 1) { + const previous = points[index - 1]! + const current = points[index]! + travelled += getPlanPointDistance(previous, current) + + if (travelled > startDistance && travelled < endDistance) { + slicedPoints.push(current) + } + } + + slicedPoints.push(endSample.point) + + return slicedPoints +} + +function getWallCurveOffsetPath( + wall: WallNode, + sideSign: 1 | -1, + offsetDistance: number, + segments = 32, +) { + return Array.from({ length: segments + 1 }, (_, index) => { + const frame = getWallCurveFrameAt(wall, index / segments) + + return { + x: frame.point.x + frame.normal.x * sideSign * offsetDistance, + y: frame.point.y + frame.normal.y * sideSign * offsetDistance, + } + }) +} + +function getCurvedWallMeasurementOverlay( + id: string, + wall: WallNode, + sideSign: 1 | -1, + measurementOffset: number, + label: string, + stroke: string, + labelFill: string, + extensionStroke: string, +): LinearMeasurementOverlay | null { + const halfThickness = (wall.thickness ?? 0.1) / 2 + const facePath = getWallCurveOffsetPath(wall, sideSign, halfThickness) + const guidePath = getWallCurveOffsetPath(wall, sideSign, halfThickness + measurementOffset) + const guideLength = getPolylineLength(guidePath) + + if (guideLength < 0.1) return null + + const labelSample = getPolylinePointAtDistance(guidePath, guideLength / 2) + if (!labelSample) return null + + let labelAngleDeg = (Math.atan2(labelSample.tangent.y, labelSample.tangent.x) * 180) / Math.PI + if (labelAngleDeg > 90) { + labelAngleDeg -= 180 + } else if (labelAngleDeg <= -90) { + labelAngleDeg += 180 + } + + const labelGapHalf = Math.min( + FLOORPLAN_MEASUREMENT_LABEL_GAP / 2, + Math.max(0, guideLength / 2 - FLOORPLAN_MEASUREMENT_LABEL_LINE_PADDING), + ) + const startPath = slicePolylineByDistance(guidePath, 0, guideLength / 2 - labelGapHalf) + const endPath = slicePolylineByDistance(guidePath, guideLength / 2 + labelGapHalf, guideLength) + const guideStart = guidePath[0]! + const guideEnd = guidePath[guidePath.length - 1]! + const faceStart = facePath[0]! + const faceEnd = facePath[facePath.length - 1]! + + return { + id, + dimensionLineStart: { + x1: guideStart.x, + y1: guideStart.y, + x2: startPath[startPath.length - 1]?.x ?? guideStart.x, + y2: startPath[startPath.length - 1]?.y ?? guideStart.y, + }, + dimensionLineEnd: { + x1: endPath[0]?.x ?? guideEnd.x, + y1: endPath[0]?.y ?? guideEnd.y, + x2: guideEnd.x, + y2: guideEnd.y, + }, + dimensionPathStart: buildSvgPolylinePath(startPath), + dimensionPathEnd: buildSvgPolylinePath(endPath), + extensionStart: { + x1: faceStart.x, + y1: faceStart.y, + x2: guideStart.x, + y2: guideStart.y, + }, + extensionEnd: { + x1: faceEnd.x, + y1: faceEnd.y, + x2: guideEnd.x, + y2: guideEnd.y, + }, + extensionStroke, + isSelected: true, + label, + labelAngleDeg, + labelFill, + labelX: labelSample.point.x, + labelY: labelSample.point.y, + stroke, + } +} + type WallFaceLine = { start: Point2D end: Point2D @@ -2865,8 +3239,57 @@ function getSelectedWallMeasurementOverlays( const centerX = minX === Number.POSITIVE_INFINITY ? 0 : (minX + maxX) / 2 const centerY = minY === Number.POSITIVE_INFINITY ? 0 : (minY + maxY) / 2 - const overlay = getWallMeasurementOverlay(wall, centerX, centerY, unit, metersPerUnit) - return overlay ? [overlay] : [] + const chord = getWallChordFrame(wall) + const midpoint = getWallCurveFrameAt(wall, 0.5).point + const fromCenter = { + x: midpoint.x - centerX, + y: midpoint.y - centerY, + } + const outwardSign: 1 | -1 = dotPlanVectors(fromCenter, chord.normal) >= 0 ? 1 : -1 + const inwardSign: 1 | -1 = outwardSign === 1 ? -1 : 1 + const outerFaceLength = getPolylineLength( + getWallCurveOffsetPath(wall, outwardSign, (wall.thickness ?? 0.1) / 2), + ) + const innerFaceLength = getPolylineLength( + getWallCurveOffsetPath(wall, inwardSign, (wall.thickness ?? 0.1) / 2), + ) + const overlays: LinearMeasurementOverlay[] = [] + + if (outerFaceLength >= 0.1) { + const overlay = getCurvedWallMeasurementOverlay( + `${wall.id}:outer-face`, + wall, + outwardSign, + FLOORPLAN_WALL_OUTER_MEASUREMENT_OFFSET, + formatMeasurement(outerFaceLength, unit, metersPerUnit), + FLOORPLAN_WALL_OUTER_MEASUREMENT_STROKE, + FLOORPLAN_WALL_OUTER_MEASUREMENT_TEXT, + FLOORPLAN_WALL_OUTER_MEASUREMENT_EXTENSION, + ) + + if (overlay) { + overlays.push(overlay) + } + } + + if (innerFaceLength >= 0.1) { + const overlay = getCurvedWallMeasurementOverlay( + `${wall.id}:inner-face`, + wall, + inwardSign, + FLOORPLAN_WALL_INNER_MEASUREMENT_OFFSET, + formatMeasurement(innerFaceLength, unit, metersPerUnit), + FLOORPLAN_WALL_INNER_MEASUREMENT_STROKE, + FLOORPLAN_WALL_INNER_MEASUREMENT_TEXT, + FLOORPLAN_WALL_INNER_MEASUREMENT_EXTENSION, + ) + + if (overlay) { + overlays.push(overlay) + } + } + + return overlays } const faceContext = getWallMeasurementFaceContext(selectedWallEntry, wallPolygons) @@ -4102,6 +4525,7 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({ unit, metersPerUnit, isGuideTraceVisible, + floorplanSceneRotationDeg, }: { canFocusGeometry: boolean canSelectSlabs: boolean @@ -4137,6 +4561,7 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({ unit: 'metric' | 'imperial' metersPerUnit: number | null isGuideTraceVisible: boolean + floorplanSceneRotationDeg: number }) { const selectedWallEntries = wallPolygons.filter(({ wall }) => selectedIdSet.has(wall.id)) const wallMeasurements = @@ -4487,6 +4912,377 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({ const markerX = (p1!.x + p2!.x + p3!.x + p4!.x) / 4 const markerY = (p1!.y + p2!.y + p3!.y + p4!.y) / 4 const windowOpeningShape = opening.openingShape ?? 'rectangle' + const windowType = opening.windowType ?? 'fixed' + const windowOpenAmount = Math.max(0, Math.min(1, opening.operationState ?? 0)) + const innerPoints = [insetInnerStart, insetInnerEnd, insetOuterEnd, insetOuterStart] + const innerPolygonPoints = formatPolygonPoints(innerPoints) + const innerCenterX = (centerStart.x + centerEnd.x) / 2 + const innerCenterY = (centerStart.y + centerEnd.y) / 2 + const innerSpan = Math.hypot(centerEnd.x - centerStart.x, centerEnd.y - centerStart.y) + const panelNormalHalf = normalLength * 0.18 + const panelNormalOffset = normalLength * 0.11 + const detailColor = isDeleteHovered ? palette.deleteStroke : symbolStroke + const windowTypeDetails = (() => { + if (windowType === 'sliding') { + const panelSpan = innerSpan * 0.56 + const panelCenterOffset = innerSpan * 0.12 + const slideTravel = innerSpan * 0.18 * windowOpenAmount + const makePanelPoints = (alongOffset: number, normalOffset: number, travel = 0) => { + const start = { + x: + innerCenterX - + tangentX * (panelSpan / 2 - alongOffset - travel) + + normalX * normalOffset, + y: + innerCenterY - + tangentY * (panelSpan / 2 - alongOffset - travel) + + normalY * normalOffset, + } + const end = { + x: + innerCenterX + + tangentX * (panelSpan / 2 + alongOffset - travel) + + normalX * normalOffset, + y: + innerCenterY + + tangentY * (panelSpan / 2 + alongOffset - travel) + + normalY * normalOffset, + } + return formatPolygonPoints([ + { + x: start.x - normalX * panelNormalHalf, + y: start.y - normalY * panelNormalHalf, + }, + { + x: end.x - normalX * panelNormalHalf, + y: end.y - normalY * panelNormalHalf, + }, + { + x: end.x + normalX * panelNormalHalf, + y: end.y + normalY * panelNormalHalf, + }, + { + x: start.x + normalX * panelNormalHalf, + y: start.y + normalY * panelNormalHalf, + }, + ]) + } + + return ( + <> + + + + ) + } + + if (windowType === 'casement') { + const isFrench = (opening.casementStyle ?? 'single') === 'french' + if (isFrench) { + const leftHinge = { + x: centerStart.x, + y: centerStart.y, + } + const rightHinge = { + x: centerEnd.x, + y: centerEnd.y, + } + const meet = { x: innerCenterX, y: innerCenterY } + const swingOffset = panelNormalHalf * (0.2 + windowOpenAmount * 1.1) + return ( + <> + + + + ) + } + + const hingeOnLeft = (opening.hingesSide ?? 'left') === 'left' + const hinge = hingeOnLeft ? centerStart : centerEnd + const strike = hingeOnLeft ? centerEnd : centerStart + const swingOffset = panelNormalHalf * (0.2 + windowOpenAmount * 1.3) + return ( + + ) + } + + if (windowType === 'awning' || windowType === 'hopper') { + const swingDown = + windowType === 'hopper' || (opening.awningDirection ?? 'up') === 'down' + const hingeMid = swingDown + ? { + x: (insetOuterStart.x + insetOuterEnd.x) / 2, + y: (insetOuterStart.y + insetOuterEnd.y) / 2, + } + : { + x: (insetInnerStart.x + insetInnerEnd.x) / 2, + y: (insetInnerStart.y + insetInnerEnd.y) / 2, + } + const openEdgeMid = swingDown + ? { + x: (insetInnerStart.x + insetInnerEnd.x) / 2, + y: + (insetInnerStart.y + insetInnerEnd.y) / 2 - + normalY * panelNormalHalf * windowOpenAmount, + } + : { + x: (insetOuterStart.x + insetOuterEnd.x) / 2, + y: + (insetOuterStart.y + insetOuterEnd.y) / 2 + + normalY * panelNormalHalf * windowOpenAmount, + } + const openEdgeMidAdjusted = { + x: + openEdgeMid.x + + normalX * panelNormalHalf * windowOpenAmount * (swingDown ? -1 : 1), + y: openEdgeMid.y, + } + return ( + <> + + + + ) + } + + if (windowType === 'single-hung' || windowType === 'double-hung') { + const travelOffset = normalLength * 0.18 * windowOpenAmount + const splitOffset = normalLength * 0.02 + const topRailStart = { + x: + innerCenterX - + tangentX * innerSpan * 0.34 - + normalX * (splitOffset + (windowType === 'double-hung' ? -travelOffset : 0)), + y: + innerCenterY - + tangentY * innerSpan * 0.34 - + normalY * (splitOffset + (windowType === 'double-hung' ? -travelOffset : 0)), + } + const topRailEnd = { + x: + innerCenterX + + tangentX * innerSpan * 0.34 - + normalX * (splitOffset + (windowType === 'double-hung' ? -travelOffset : 0)), + y: + innerCenterY + + tangentY * innerSpan * 0.34 - + normalY * (splitOffset + (windowType === 'double-hung' ? -travelOffset : 0)), + } + const bottomRailStart = { + x: + innerCenterX - + tangentX * innerSpan * 0.34 + + normalX * (splitOffset + travelOffset), + y: + innerCenterY - + tangentY * innerSpan * 0.34 + + normalY * (splitOffset + travelOffset), + } + const bottomRailEnd = { + x: + innerCenterX + + tangentX * innerSpan * 0.34 + + normalX * (splitOffset + travelOffset), + y: + innerCenterY + + tangentY * innerSpan * 0.34 + + normalY * (splitOffset + travelOffset), + } + return ( + <> + + + {windowType === 'single-hung' ? ( + + ) : null} + + ) + } + + if (windowType === 'louvered') { + return ( + <> + {[0.2, 0.4, 0.6, 0.8].map((ratio) => { + const anchor = { + x: centerStart.x + (centerEnd.x - centerStart.x) * ratio, + y: centerStart.y + (centerEnd.y - centerStart.y) * ratio, + } + return ( + + ) + })} + + ) + } + + if (windowType === 'bay' || windowType === 'bow') { + return ( + <> + + + + ) + } + + return ( + <> + + {[0.25, 0.5, 0.75].map((ratio) => { + const topPoint = { + x: insetInnerStart.x + (insetInnerEnd.x - insetInnerStart.x) * ratio, + y: insetInnerStart.y + (insetInnerEnd.y - insetInnerStart.y) * ratio, + } + const bottomPoint = { + x: insetOuterStart.x + (insetOuterEnd.x - insetOuterStart.x) * ratio, + y: insetOuterStart.y + (insetOuterEnd.y - insetOuterStart.y) * ratio, + } + const midPoint = { + x: (topPoint.x + bottomPoint.x) / 2, + y: (topPoint.y + bottomPoint.y) / 2, + } + const mullionHalf = normalLength * 0.18 + + return ( + + ) + })} + + ) + })() if (opening.openingKind === 'opening') { const detailInset = Math.min(tangentLength * 0.14, 0.18) @@ -4677,53 +5473,12 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({ /> - - {[0.25, 0.5, 0.75].map((ratio) => { - const topPoint = { - x: insetInnerStart.x + (insetInnerEnd.x - insetInnerStart.x) * ratio, - y: insetInnerStart.y + (insetInnerEnd.y - insetInnerStart.y) * ratio, - } - const bottomPoint = { - x: insetOuterStart.x + (insetOuterEnd.x - insetOuterStart.x) * ratio, - y: insetOuterStart.y + (insetOuterEnd.y - insetOuterStart.y) * ratio, - } - const midPoint = { - x: (topPoint.x + bottomPoint.x) / 2, - y: (topPoint.y + bottomPoint.y) / 2, - } - const mullionHalf = normalLength * 0.18 - - return ( - - ) - })} + {windowTypeDetails} {isSelected ? ( <> 0 ? svgP1 - : { x: svgP1.x - nx * foldingSpan, y: svgP1.y - ny * foldingSpan } + : { + x: svgP1.x - nx * foldingSpan, + y: svgP1.y - ny * foldingSpan, + } const pocketTrackEnd = pocketSign > 0 ? { x: svgP2.x + nx * foldingSpan, y: svgP2.y + ny * foldingSpan } @@ -5142,10 +5900,22 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({ py * swingSign * faceOffset, } return [ - { x: start.x - px * leafHalfThickness, y: start.y - py * leafHalfThickness }, - { x: end.x - px * leafHalfThickness, y: end.y - py * leafHalfThickness }, - { x: end.x + px * leafHalfThickness, y: end.y + py * leafHalfThickness }, - { x: start.x + px * leafHalfThickness, y: start.y + py * leafHalfThickness }, + { + x: start.x - px * leafHalfThickness, + y: start.y - py * leafHalfThickness, + }, + { + x: end.x - px * leafHalfThickness, + y: end.y - py * leafHalfThickness, + }, + { + x: end.x + px * leafHalfThickness, + y: end.y + py * leafHalfThickness, + }, + { + x: start.x + px * leafHalfThickness, + y: start.y + py * leafHalfThickness, + }, ] .map((point) => `${point.x},${point.y}`) .join(' ') @@ -5175,12 +5945,18 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({ [ { key: 'left', - hingePoint: { x: cx - nx * (width / 2), y: cy - ny * (width / 2) }, + hingePoint: { + x: cx - nx * (width / 2), + y: cy - ny * (width / 2), + }, strikePoint: { x: cx, y: cy }, }, { key: 'right', - hingePoint: { x: cx + nx * (width / 2), y: cy + ny * (width / 2) }, + hingePoint: { + x: cx + nx * (width / 2), + y: cy + ny * (width / 2), + }, strikePoint: { x: cx, y: cy }, }, ] as const @@ -5800,11 +6576,54 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({ className="wall-dimension" measurements={wallMeasurements} palette={palette} + sceneRotationDeg={floorplanSceneRotationDeg} /> ) }) +const FloorplanDraftAngleLayer = memo(function FloorplanDraftAngleLayer({ + overlays, +}: { + overlays: DraftWallAngleOverlay[] +}) { + if (overlays.length === 0) { + return null + } + + return ( + <> + {overlays.map((overlay) => ( + + + + {overlay.label} + + + ))} + + ) +}) + const FloorplanFenceLayer = memo(function FloorplanFenceLayer({ canFocusGeometry, canSelectGeometry, @@ -7673,6 +8492,214 @@ const FloorplanWallCurveHandleLayer = memo(function FloorplanWallCurveHandleLaye ) }) +const FloorplanWallMoveHandleLayer = memo(function FloorplanWallMoveHandleLayer({ + handles, + hoveredHandleId, + onHandleHoverChange, + onWallMovePointerDown, +}: { + handles: FloorplanWallMoveHandle[] + hoveredHandleId: string | null + onHandleHoverChange: (handleId: string | null) => void + onWallMovePointerDown: (wall: WallNode, event: ReactPointerEvent) => void +}) { + const bodyLength = FLOORPLAN_WALL_MOVE_ARROW_BODY_LENGTH + const bodyStrokeWidth = FLOORPLAN_WALL_MOVE_ARROW_BODY_WIDTH + const headLength = FLOORPLAN_WALL_MOVE_ARROW_HEAD_LENGTH + const hitRadius = FLOORPLAN_WALL_MOVE_ARROW_HIT_RADIUS + const tailLength = bodyStrokeWidth * 0.9 + + return ( + <> + {handles.map((handle) => { + const isHovered = hoveredHandleId === handle.id + const fill = isHovered + ? FLOORPLAN_WALL_MOVE_ARROW_HOVER_COLOR + : FLOORPLAN_WALL_MOVE_ARROW_COLOR + const headPoints = buildSvgArrowHeadPoints({ x: headLength / 2, y: 0 }, 0, headLength) + const shaftX = -bodyLength + tailLength + const shaftWidth = bodyLength - tailLength + + return ( + onHandleHoverChange(handle.id)} + onPointerLeave={() => onHandleHoverChange(null)} + transform={`translate(${toSvgX(handle.point.x)} ${toSvgY(handle.point.y)}) rotate(${handle.rotationDeg})`} + > + + + + + onWallMovePointerDown(handle.wall, event)} + pointerEvents="all" + r={hitRadius} + stroke="transparent" + style={{ cursor: EDITOR_CURSOR }} + vectorEffect="non-scaling-stroke" + /> + + ) + })} + + ) +}) + +const FloorplanFenceMoveHandleLayer = memo(function FloorplanFenceMoveHandleLayer({ + handles, + hoveredHandleId, + onHandleHoverChange, + onFenceMovePointerDown, +}: { + handles: FloorplanFenceMoveHandle[] + hoveredHandleId: string | null + onHandleHoverChange: (handleId: string | null) => void + onFenceMovePointerDown: (fence: FenceNode, event: ReactPointerEvent) => void +}) { + const bodyLength = FLOORPLAN_WALL_MOVE_ARROW_BODY_LENGTH + const bodyStrokeWidth = FLOORPLAN_WALL_MOVE_ARROW_BODY_WIDTH + const headLength = FLOORPLAN_WALL_MOVE_ARROW_HEAD_LENGTH + const hitRadius = FLOORPLAN_WALL_MOVE_ARROW_HIT_RADIUS + const tailLength = bodyStrokeWidth * 0.9 + + return ( + <> + {handles.map((handle) => { + const isHovered = hoveredHandleId === handle.id + const fill = isHovered + ? FLOORPLAN_WALL_MOVE_ARROW_HOVER_COLOR + : FLOORPLAN_WALL_MOVE_ARROW_COLOR + const headPoints = buildSvgArrowHeadPoints({ x: headLength / 2, y: 0 }, 0, headLength) + const shaftX = -bodyLength + tailLength + const shaftWidth = bodyLength - tailLength + + return ( + onHandleHoverChange(handle.id)} + onPointerLeave={() => onHandleHoverChange(null)} + transform={`translate(${toSvgX(handle.point.x)} ${toSvgY(handle.point.y)}) rotate(${handle.rotationDeg})`} + > + + + + + onFenceMovePointerDown(handle.fence, event)} + pointerEvents="all" + r={hitRadius} + stroke="transparent" + style={{ cursor: EDITOR_CURSOR }} + vectorEffect="non-scaling-stroke" + /> + + ) + })} + + ) +}) + const FloorplanPolygonHandleLayer = memo(function FloorplanPolygonHandleLayer({ edgeHandles = [], hoveredHandleId, @@ -8042,6 +9069,7 @@ export function FloorplanPanel() { const mode = useEditor((state) => state.mode) const setPhase = useEditor((state) => state.setPhase) const setMovingFenceEndpoint = useEditor((state) => state.setMovingFenceEndpoint) + const setCurvingFence = useEditor((state) => state.setCurvingFence) const setMovingNode = useEditor((state) => state.setMovingNode) const setCurvingWall = useEditor((state) => state.setCurvingWall) const movingFenceEndpoint = useEditor((state) => state.movingFenceEndpoint) @@ -8146,6 +9174,8 @@ export function FloorplanPanel() { useState(null) const [hoveredZoneId, setHoveredZoneId] = useState(null) const [hoveredEndpointId, setHoveredEndpointId] = useState(null) + const [hoveredWallMoveHandleId, setHoveredWallMoveHandleId] = useState(null) + const [hoveredFenceMoveHandleId, setHoveredFenceMoveHandleId] = useState(null) const [hoveredWallCurveHandleId, setHoveredWallCurveHandleId] = useState(null) const [hoveredSiteHandleId, setHoveredSiteHandleId] = useState(null) const [hoveredSlabHandleId, setHoveredSlabHandleId] = useState(null) @@ -8400,7 +9430,10 @@ export function FloorplanPanel() { if (wallCurveDraft) { const wall = nextWallById.get(wallCurveDraft.wallId) if (wall) { - nextWallById.set(wall.id, { ...wall, curveOffset: wallCurveDraft.curveOffset }) + nextWallById.set(wall.id, { + ...wall, + curveOffset: wallCurveDraft.curveOffset, + }) } } @@ -10014,6 +11047,15 @@ export function FloorplanPanel() { wallCurveDraft, ]) const canCurveSelectedWall = wallCurveHandles.length > 0 + const canCurveSelectedFence = useMemo(() => { + return ( + !isOpeningPlacementActive && + !movingNode && + mode === 'select' && + floorplanSelectionTool === 'click' && + selectedFenceEntry !== null + ) + }, [floorplanSelectionTool, isOpeningPlacementActive, mode, movingNode, selectedFenceEntry]) const slabVertexHandles = useMemo(() => { if (!shouldShowSlabBoundaryHandles) { return [] @@ -10389,6 +11431,49 @@ export function FloorplanPanel() { // Keep the live draft preview cheap; full level-wide mitering here runs on every mouse move. return getWallPlanFootprint(draftWall, EMPTY_WALL_MITER_DATA) }, [draftEnd, draftStart, levelId]) + const draftWallLengthMeasurements = useMemo(() => { + if (!(isWallBuildActive && draftStart && draftEnd && isWallLongEnough(draftStart, draftEnd))) { + return [] + } + + const dx = draftEnd[0] - draftStart[0] + const dy = draftEnd[1] - draftStart[1] + const length = Math.hypot(dx, dy) + if (length < 1e-6) return [] + + const normal = { x: -dy / length, y: dx / length } + const measurement = getLinearMeasurementOverlay( + 'wall-draft:length', + { x: draftStart[0], y: draftStart[1] }, + { x: draftEnd[0], y: draftEnd[1] }, + formatMeasurement(length, unit, calibratedMetersPerUnit), + { + extensionOvershoot: FLOORPLAN_MEASUREMENT_EXTENSION_OVERSHOOT, + offsetDistance: FLOORPLAN_MEASUREMENT_OFFSET, + offsetVector: normal, + }, + ) + + return measurement + ? [ + { + ...measurement, + dashedExtensions: true, + isSelected: true, + stroke: FLOORPLAN_DRAFT_ANGLE_STROKE, + labelFill: FLOORPLAN_DRAFT_ANGLE_STROKE, + extensionStroke: FLOORPLAN_DRAFT_ANGLE_STROKE, + }, + ] + : [] + }, [calibratedMetersPerUnit, draftEnd, draftStart, isWallBuildActive, unit]) + const draftWallAngleOverlays = useMemo(() => { + if (!(isWallBuildActive && draftStart && draftEnd && isWallLongEnough(draftStart, draftEnd))) { + return [] + } + + return getDraftWallAngleOverlays(draftStart, draftEnd, walls) + }, [draftEnd, draftStart, isWallBuildActive, walls]) const draftPolygonPoints = useMemo(() => { if (isRoofBuildActive && roofDraftStart && roofDraftEnd) { const minX = Math.min(roofDraftStart[0], roofDraftEnd[0]) @@ -10739,10 +11824,22 @@ export function FloorplanPanel() { return getFloorplanActionMenuPosition( [ - { x: position.x - FLOORPLAN_SPAWN_HIT_RADIUS, y: position.y - FLOORPLAN_SPAWN_HIT_RADIUS }, - { x: position.x + FLOORPLAN_SPAWN_HIT_RADIUS, y: position.y - FLOORPLAN_SPAWN_HIT_RADIUS }, - { x: position.x + FLOORPLAN_SPAWN_HIT_RADIUS, y: position.y + FLOORPLAN_SPAWN_HIT_RADIUS }, - { x: position.x - FLOORPLAN_SPAWN_HIT_RADIUS, y: position.y + FLOORPLAN_SPAWN_HIT_RADIUS }, + { + x: position.x - FLOORPLAN_SPAWN_HIT_RADIUS, + y: position.y - FLOORPLAN_SPAWN_HIT_RADIUS, + }, + { + x: position.x + FLOORPLAN_SPAWN_HIT_RADIUS, + y: position.y - FLOORPLAN_SPAWN_HIT_RADIUS, + }, + { + x: position.x + FLOORPLAN_SPAWN_HIT_RADIUS, + y: position.y + FLOORPLAN_SPAWN_HIT_RADIUS, + }, + { + x: position.x - FLOORPLAN_SPAWN_HIT_RADIUS, + y: position.y + FLOORPLAN_SPAWN_HIT_RADIUS, + }, ], viewBox, surfaceSize, @@ -11147,6 +12244,49 @@ export function FloorplanPanel() { [gridBounds, gridSteps.majorStep], ) const floorplanUnitsPerPixel = viewBox.width / Math.max(surfaceSize.width, 1) + const wallMoveHandles = useMemo(() => { + if ( + isOpeningPlacementActive || + movingNode || + curvingWall || + mode !== 'select' || + floorplanSelectionTool !== 'click' || + !selectedWallEntry + ) { + return [] + } + + return getFloorplanWallMoveHandles(selectedWallEntry.wall) + }, [ + curvingWall, + floorplanSelectionTool, + isOpeningPlacementActive, + mode, + movingNode, + selectedWallEntry, + ]) + + const fenceMoveHandles = useMemo(() => { + if ( + floorplanSelectionTool !== 'click' || + !selectedFenceEntry || + curvingFence || + movingFenceEndpoint || + isOpeningPlacementActive || + movingNode + ) { + return [] + } + + return getFloorplanFenceMoveHandles(selectedFenceEntry.fence) + }, [ + curvingFence, + floorplanSelectionTool, + isOpeningPlacementActive, + movingFenceEndpoint, + movingNode, + selectedFenceEntry, + ]) useEffect(() => { setReferenceScaleUnit(unit === 'imperial' ? 'feet' : 'meters') @@ -11348,22 +12488,30 @@ export function FloorplanPanel() { Math.max(0.8, nextOuterSize - dragState.shaftWallThickness * 2), ) const nextCabWidth = nextShaftWidth - useLiveNodeOverrides - .getState() - .set(dragState.elevatorId, { shaftWidth: nextShaftWidth, width: nextCabWidth }) + useLiveNodeOverrides.getState().set(dragState.elevatorId, { + shaftWidth: nextShaftWidth, + width: nextCabWidth, + }) setCursorPoint(planPoint) - return { shaftWidth: nextShaftWidth, width: nextCabWidth } satisfies Partial + return { + shaftWidth: nextShaftWidth, + width: nextCabWidth, + } satisfies Partial } const nextShaftDepth = roundPlanMeters( Math.max(0.8, nextOuterSize - dragState.shaftWallThickness * 2), ) const nextCabDepth = nextShaftDepth - useLiveNodeOverrides - .getState() - .set(dragState.elevatorId, { depth: nextCabDepth, shaftDepth: nextShaftDepth }) + useLiveNodeOverrides.getState().set(dragState.elevatorId, { + depth: nextCabDepth, + shaftDepth: nextShaftDepth, + }) setCursorPoint(planPoint) - return { depth: nextCabDepth, shaftDepth: nextShaftDepth } satisfies Partial + return { + depth: nextCabDepth, + shaftDepth: nextShaftDepth, + } satisfies Partial }, [], ) @@ -14980,20 +16128,41 @@ export function FloorplanPanel() { }, [deleteNode, selectedItemEntry, setSelection], ) - const handleSelectedWallMove = useCallback( - (event: ReactMouseEvent) => { + const handleWallMoveHandlePointerDown = useCallback( + (wall: WallNode, event: ReactPointerEvent) => { + if (event.button !== 0) { + return + } + + event.preventDefault() event.stopPropagation() - const wall = selectedWallEntry?.wall - if (!wall) { + sfxEmitter.emit('sfx:item-pick') + setHoveredWallMoveHandleId(null) + setMovingFenceEndpoint(null) + setCurvingWall(null) + setMovingNode(wall) + setSelection({ selectedIds: [] }) + }, + [setCurvingWall, setMovingFenceEndpoint, setMovingNode, setSelection], + ) + const handleFenceMoveHandlePointerDown = useCallback( + (fence: FenceNode, event: ReactPointerEvent) => { + if (event.button !== 0) { return } + event.preventDefault() + event.stopPropagation() + sfxEmitter.emit('sfx:item-pick') - setMovingNode(wall) + setHoveredFenceMoveHandleId(null) + setMovingFenceEndpoint(null) + setCurvingFence(null) + setMovingNode(fence) setSelection({ selectedIds: [] }) }, - [selectedWallEntry, setMovingNode, setSelection], + [setCurvingFence, setMovingFenceEndpoint, setMovingNode, setSelection], ) const duplicateSelectedWall = useCallback(() => { const wall = selectedWallEntry?.wall @@ -15005,6 +16174,7 @@ export function FloorplanPanel() { const cloned = structuredClone(wall) as Record delete cloned.id + cloned.children = [] cloned.metadata = { ...(typeof cloned.metadata === 'object' && cloned.metadata !== null ? cloned.metadata : {}), @@ -15024,6 +16194,35 @@ export function FloorplanPanel() { temporal.resume() } }, [selectedWallEntry, setMovingNode, setSelection]) + const duplicateSelectedFence = useCallback(() => { + const fence = selectedFenceEntry?.fence + if (!fence?.parentId) { + return + } + + sfxEmitter.emit('sfx:item-pick') + + const cloned = structuredClone(fence) as Record + delete cloned.id + + cloned.metadata = { + ...(typeof cloned.metadata === 'object' && cloned.metadata !== null ? cloned.metadata : {}), + isNew: true, + } + + const temporal = useScene.temporal.getState() + temporal.pause() + try { + const duplicate = FenceNodeSchema.parse(cloned) + useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId) + setMovingNode(duplicate) + setSelection({ selectedIds: [] }) + } catch (error) { + console.error('Failed to duplicate fence', error) + } finally { + temporal.resume() + } + }, [selectedFenceEntry, setMovingNode, setSelection]) const handleSelectedWallDuplicate = useCallback( (event: ReactMouseEvent) => { event.stopPropagation() @@ -15031,6 +16230,13 @@ export function FloorplanPanel() { }, [duplicateSelectedWall], ) + const handleSelectedFenceDuplicate = useCallback( + (event: ReactMouseEvent) => { + event.stopPropagation() + duplicateSelectedFence() + }, + [duplicateSelectedFence], + ) const handleSelectedWallCurve = useCallback( (event: ReactMouseEvent) => { event.stopPropagation() @@ -15046,6 +16252,21 @@ export function FloorplanPanel() { }, [canCurveSelectedWall, selectedWallEntry, setCurvingWall, setSelection], ) + const handleSelectedFenceCurve = useCallback( + (event: ReactMouseEvent) => { + event.stopPropagation() + + const fence = selectedFenceEntry?.fence + if (!(fence && canCurveSelectedFence)) { + return + } + + sfxEmitter.emit('sfx:item-pick') + setCurvingFence(fence) + setSelection({ selectedIds: [] }) + }, + [canCurveSelectedFence, selectedFenceEntry, setCurvingFence, setSelection], + ) const handleSelectedWallDelete = useCallback( (event: ReactMouseEvent) => { event.stopPropagation() @@ -15337,21 +16558,6 @@ export function FloorplanPanel() { }, [deleteNode, selectedCeilingEntry, setSelection], ) - const handleSelectedFenceMove = useCallback( - (event: ReactMouseEvent) => { - event.stopPropagation() - - const fence = selectedFenceEntry?.fence - if (!fence) { - return - } - - sfxEmitter.emit('sfx:item-pick') - setMovingNode(fence) - setSelection({ selectedIds: [] }) - }, - [selectedFenceEntry, setMovingNode, setSelection], - ) const handleSelectedFenceDelete = useCallback( (event: ReactMouseEvent) => { event.stopPropagation() @@ -16638,6 +17844,7 @@ export function FloorplanPanel() { handleElevatorHoverChange(null) handleZoneHoverChange(null) setHoveredEndpointId(null) + setHoveredWallMoveHandleId(null) setHoveredSiteHandleId(null) setHoveredSlabHandleId(null) setHoveredCeilingHandleId(null) @@ -16766,6 +17973,7 @@ export function FloorplanPanel() { handleElevatorHoverChange(null) handleZoneHoverChange(null) setHoveredEndpointId(null) + setHoveredWallMoveHandleId(null) floorplanMarqueeSnapPointRef.current = snappedPoint syncPreviewSelectedIds([]) setFloorplanMarqueeState({ @@ -17223,7 +18431,8 @@ export function FloorplanPanel() { fence={{ position: selectedFenceActionMenuPosition, onDelete: handleSelectedFenceDelete, - onMove: handleSelectedFenceMove, + onDuplicate: duplicateSelectedFence, + onCurve: canCurveSelectedFence ? handleSelectedFenceCurve : undefined, }} item={{ position: selectedItemActionMenuPosition, @@ -17268,7 +18477,6 @@ export function FloorplanPanel() { onCurve: canCurveSelectedWall ? handleSelectedWallCurve : undefined, onDelete: handleSelectedWallDelete, onDuplicate: handleSelectedWallDuplicate, - onMove: handleSelectedWallMove, }} /> @@ -17398,7 +18606,9 @@ export function FloorplanPanel() { onPointerMove={handleSvgPointerMove} onPointerUp={endPanning} ref={svgRef} - style={{ cursor: referenceScaleDraft ? 'crosshair' : EDITOR_CURSOR }} + style={{ + cursor: referenceScaleDraft ? 'crosshair' : EDITOR_CURSOR, + }} viewBox={`${viewBox.minX} ${viewBox.minY} ${viewBox.width} ${viewBox.height}`} > @@ -17488,6 +18698,7 @@ export function FloorplanPanel() { isDeleteMode={isDeleteMode} isGuideTraceVisible={isGuideTraceVisible} metersPerUnit={calibratedMetersPerUnit} + floorplanSceneRotationDeg={floorplanSceneRotationDeg} onCeilingDoubleClick={handleCeilingDoubleClick} onCeilingHoverChange={handleCeilingHoverChange} onCeilingSelect={handleCeilingSelect} @@ -17613,14 +18824,25 @@ export function FloorplanPanel() { className="opening-placement-dimension" measurements={movingOpeningPlacementMeasurements} palette={palette} + sceneRotationDeg={floorplanSceneRotationDeg} /> + + + + {/* Zone labels: always visible so users can click to select zones from any mode */} + + + +
)} + {isFirstPersonMode && ( + useEditor.getState().setFirstPersonMode(false)} + /> + )} {viewerBanner} {projectId ? : null} @@ -1172,12 +1176,6 @@ export default function Editor({ /> - {/* First-person overlay — rendered on top of normal layout */} - {isFirstPersonMode && ( -
- useEditor.getState().setFirstPersonMode(false)} /> -
- )} )} @@ -1232,13 +1230,10 @@ export default function Editor({
- - {/* First-person overlay — rendered on top of normal layout */} - {isFirstPersonMode && ( -
+ {isFirstPersonMode && ( useEditor.getState().setFirstPersonMode(false)} /> -
- )} + )} + )}
diff --git a/packages/editor/src/components/editor/wall-move-side-handles.tsx b/packages/editor/src/components/editor/wall-move-side-handles.tsx index 43bd9019d..83ac1db17 100644 --- a/packages/editor/src/components/editor/wall-move-side-handles.tsx +++ b/packages/editor/src/components/editor/wall-move-side-handles.tsx @@ -3,13 +3,16 @@ import { type AnyNodeId, DEFAULT_WALL_HEIGHT, + type FenceNode, + getWallCurveFrameAt, getWallThickness, + isCurvedWall, sceneRegistry, useScene, type WallNode, } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' -import { createPortal, type ThreeEvent } from '@react-three/fiber' +import { createPortal, type ThreeEvent, useThree } from '@react-three/fiber' import { useEffect, useMemo, useState } from 'react' import { BufferGeometry, @@ -18,6 +21,7 @@ import { DoubleSide, Float32BufferAttribute, type Object3D, + OrthographicCamera, } from 'three' import { sfxEmitter } from '../../lib/sfx-bus' import useEditor from '../../store/use-editor' @@ -30,7 +34,6 @@ const ARROW_COLOR = '#8381ed' const ARROW_HOVER_COLOR = '#a5b4fc' type WallMoveHandle = { - direction: [number, number] key: string position: [number, number, number] rotationY: number @@ -87,13 +90,13 @@ export function WallMoveSideHandles() { const curvingFence = useEditor((state) => state.curvingFence) const selectedId = selectedIds.length === 1 ? selectedIds[0] : null - const wall = useScene((state) => { + const selectedNode = useScene((state) => { const node = selectedId ? state.nodes[selectedId as AnyNodeId] : null - return node?.type === 'wall' ? node : null + return node?.type === 'wall' || node?.type === 'fence' ? node : null }) const shouldRender = - Boolean(wall) && + Boolean(selectedNode) && !isFloorplanHovered && mode !== 'delete' && !movingNode && @@ -102,9 +105,13 @@ export function WallMoveSideHandles() { !curvingWall && !curvingFence - if (!shouldRender || !wall) return null + if (!shouldRender || !selectedNode) return null - return + return selectedNode.type === 'wall' ? ( + + ) : ( + + ) } function WallMoveSideHandlesForWall({ wall }: { wall: WallNode }) { @@ -157,6 +164,11 @@ function WallMoveSideHandlesForWall({ wall }: { wall: WallNode }) { function WallMoveArrowHandle({ wall, handle }: { wall: WallNode; handle: WallMoveHandle }) { const [isHovered, setIsHovered] = useState(false) const arrowGeometry = useMemo(() => createArrowHandleGeometry(), []) + const { camera } = useThree() + + const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1 + + const scale = (isHovered ? 1.12 : 1) * zoom useEffect(() => { return () => { @@ -183,11 +195,7 @@ function WallMoveArrowHandle({ wall, handle }: { wall: WallNode; handle: WallMov } return ( - + createArrowHandleGeometry(), []) + const { camera } = useThree() + + const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1 + const scale = (isHovered ? 1.12 : 1) * zoom + + useEffect(() => { + return () => { + if (document.body.style.cursor === 'grab' || document.body.style.cursor === 'grabbing') { + document.body.style.cursor = '' + } + } + }, []) + + useEffect(() => () => arrowGeometry.dispose(), [arrowGeometry]) + + const activateFenceMove = (event: ThreeEvent) => { + event.stopPropagation() + event.nativeEvent.preventDefault() + document.body.style.cursor = 'grabbing' + + sfxEmitter.emit('sfx:item-pick') + useEditor.getState().setMovingNode(fence) + useEditor.getState().setMovingWallEndpoint(null) + useEditor.getState().setMovingFenceEndpoint(null) + useEditor.getState().setCurvingWall(null) + useEditor.getState().setCurvingFence(null) + useViewer.getState().setSelection({ selectedIds: [] }) + } + + return ( + + { + event.stopPropagation() + setIsHovered(true) + document.body.style.cursor = 'grab' + }} + onPointerLeave={(event) => { + event.stopPropagation() + setIsHovered(false) + if (document.body.style.cursor === 'grab') { + document.body.style.cursor = '' + } + }} + renderOrder={1002} + > + + + + + ) +} + function getWallMoveHandles(wall: WallNode): WallMoveHandle[] { const dx = wall.end[0] - wall.start[0] const dz = wall.end[1] - wall.start[1] @@ -228,11 +301,13 @@ function getWallMoveHandles(wall: WallNode): WallMoveHandle[] { return [] } - const normal: [number, number] = [-dz / length, dx / length] - const midpoint: [number, number] = [ - (wall.start[0] + wall.end[0]) / 2, - (wall.start[1] + wall.end[1]) / 2, - ] + const frame = isCurvedWall(wall) ? getWallCurveFrameAt(wall, 0.5) : null + const normal: [number, number] = frame + ? [frame.normal.x, frame.normal.y] + : [-dz / length, dx / length] + const midpoint: [number, number] = frame + ? [frame.point.x, frame.point.y] + : [(wall.start[0] + wall.end[0]) / 2, (wall.start[1] + wall.end[1]) / 2] const wallHeight = wall.height ?? DEFAULT_WALL_HEIGHT const handleHeight = Math.max(wallHeight - HANDLE_TOP_INSET, HANDLE_MIN_HEIGHT) const offset = Math.max(getWallThickness(wall) / 2 + HANDLE_OFFSET, HANDLE_MIN_OFFSET) @@ -243,6 +318,77 @@ function getWallMoveHandles(wall: WallNode): WallMoveHandle[] { ] } +function WallMoveSideHandlesForFence({ fence }: { fence: FenceNode }) { + const [levelObject, setLevelObject] = useState(() => + fence.parentId ? (sceneRegistry.nodes.get(fence.parentId) ?? null) : null, + ) + + useEffect(() => { + let frameId = 0 + + const resolveLevelObject = () => { + const nextLevelObject = fence.parentId + ? (sceneRegistry.nodes.get(fence.parentId) ?? null) + : null + setLevelObject((currentLevelObject) => { + if (currentLevelObject === nextLevelObject) { + return currentLevelObject + } + return nextLevelObject + }) + + if (!nextLevelObject) { + frameId = window.requestAnimationFrame(resolveLevelObject) + } + } + + resolveLevelObject() + + return () => { + if (frameId) { + window.cancelAnimationFrame(frameId) + } + } + }, [fence.parentId]) + + const handles = useMemo(() => getFenceMoveHandles(fence), [fence]) + + if (!levelObject || handles.length === 0) return null + + return createPortal( + + {handles.map((handle) => ( + + ))} + , + levelObject, + ) +} + +function getFenceMoveHandles(fence: FenceNode): WallMoveHandle[] { + const dx = fence.end[0] - fence.start[0] + const dz = fence.end[1] - fence.start[1] + const length = Math.hypot(dx, dz) + + if (length < 1e-6) { + return [] + } + + const midpoint: [number, number] = [ + (fence.start[0] + fence.end[0]) / 2, + (fence.start[1] + fence.end[1]) / 2, + ] + const normal: [number, number] = [-dz / length, dx / length] + const fenceHeight = fence.height ?? 1.8 + const handleHeight = Math.max(fenceHeight - HANDLE_TOP_INSET, HANDLE_MIN_HEIGHT) + const offset = Math.max((fence.thickness ?? 0.1) / 2 + HANDLE_OFFSET, HANDLE_MIN_OFFSET) + + return [ + buildWallMoveHandle('front', midpoint, normal, offset, handleHeight), + buildWallMoveHandle('back', midpoint, [-normal[0], -normal[1]], offset, handleHeight), + ] +} + function buildWallMoveHandle( key: string, midpoint: [number, number], @@ -251,7 +397,6 @@ function buildWallMoveHandle( height: number, ): WallMoveHandle { return { - direction, key, position: [midpoint[0] + direction[0] * offset, height, midpoint[1] + direction[1] * offset], rotationY: Math.atan2(-direction[1], direction[0]), diff --git a/packages/editor/src/components/tools/building/move-building-tool.tsx b/packages/editor/src/components/tools/building/move-building-tool.tsx index 0a1e96fd4..8925119b5 100644 --- a/packages/editor/src/components/tools/building/move-building-tool.tsx +++ b/packages/editor/src/components/tools/building/move-building-tool.tsx @@ -8,7 +8,6 @@ import { useScene, } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' -import { useFrame } from '@react-three/fiber' import { useCallback, useEffect, useRef, useState } from 'react' import * as THREE from 'three' import { markToolCancelConsumed } from '../../../hooks/use-keyboard' diff --git a/packages/editor/src/components/tools/column/move-column-tool.tsx b/packages/editor/src/components/tools/column/move-column-tool.tsx index ae02e102b..a8a2be5c7 100644 --- a/packages/editor/src/components/tools/column/move-column-tool.tsx +++ b/packages/editor/src/components/tools/column/move-column-tool.tsx @@ -28,6 +28,11 @@ export function MoveColumnTool({ node }: { node: ColumnNodeType }) { useEffect(() => { useScene.temporal.getState().pause() let committed = false + const meta = + typeof node.metadata === 'object' && node.metadata !== null + ? (node.metadata as Record) + : {} + const isNew = !!meta.isNew const applyPreview = (position: [number, number, number]) => { setPreviewPosition(position) @@ -54,7 +59,7 @@ export function MoveColumnTool({ node }: { node: ColumnNodeType }) { committed = true useLiveTransforms.getState().clear(nodeId) useScene.temporal.getState().resume() - useScene.getState().updateNode(nodeId, { position }) + useScene.getState().updateNode(nodeId, { position, ...(isNew ? { metadata: {} } : {}) }) } else if (node.parentId) { const column = ColumnNode.parse({ ...node, diff --git a/packages/editor/src/components/tools/door/move-door-tool.tsx b/packages/editor/src/components/tools/door/move-door-tool.tsx index ee1a80c3c..b248fdb7c 100644 --- a/packages/editor/src/components/tools/door/move-door-tool.tsx +++ b/packages/editor/src/components/tools/door/move-door-tool.tsx @@ -68,6 +68,17 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod const markWallDirty = (wallId: string | null) => { if (wallId) useScene.getState().dirtyNodes.add(wallId as AnyNodeId) } + const lastWallDirtyAt = new Map() + const markWallDirtyThrottled = (wallId: string | null) => { + if (!wallId) return + const now = globalThis.performance?.now?.() ?? Date.now() + const last = lastWallDirtyAt.get(wallId) ?? 0 + // Wall rebuilds can trigger expensive CSG; throttle live previews to avoid FPS collapse. + if (now - last > 120) { + lastWallDirtyAt.set(wallId, now) + markWallDirty(wallId) + } + } const getLevelId = () => useViewer.getState().selection.levelId const getLevelYOffset = () => { @@ -144,7 +155,7 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod }) if (prevWallId && prevWallId !== event.node.id) markWallDirty(prevWallId) - markWallDirty(event.node.id) + markWallDirtyThrottled(event.node.id) const valid = !hasWallChildOverlap( event.node.id, @@ -212,7 +223,7 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod position: [clampedX, clampedY, 0], rotation: itemRotation, }) - markWallDirty(event.node.id) + markWallDirtyThrottled(event.node.id) const valid = !hasWallChildOverlap( event.node.id, diff --git a/packages/editor/src/components/tools/elevator/elevator-tool.tsx b/packages/editor/src/components/tools/elevator/elevator-tool.tsx index 409628f27..4b81bd917 100644 --- a/packages/editor/src/components/tools/elevator/elevator-tool.tsx +++ b/packages/editor/src/components/tools/elevator/elevator-tool.tsx @@ -69,6 +69,10 @@ function createElevatorPreviewGeometry(): THREE.BufferGeometry { ) } +function createElevatorPreviewEdgeGeometry(): THREE.BufferGeometry { + return new THREE.EdgesGeometry(createElevatorPreviewGeometry()) +} + function commitElevatorPlacement( buildingId: BuildingNode['id'], selectedLevelId: LevelNode['id'] | null, @@ -113,6 +117,7 @@ export const ElevatorTool: React.FC = ({ buildingId, levelId, const rotationRef = useRef(0) const previousGridPosRef = useRef<[number, number] | null>(null) const previewGeometry = useMemo(() => createElevatorPreviewGeometry(), []) + const previewEdgeGeometry = useMemo(() => createElevatorPreviewEdgeGeometry(), []) useEffect(() => { const currentBuildingId = resolveCurrentBuildingId({ @@ -201,13 +206,45 @@ export const ElevatorTool: React.FC = ({ buildingId, levelId, - - + + + + + + + + + - + + + + + + + + + ) diff --git a/packages/editor/src/components/tools/fence/fence-tool.tsx b/packages/editor/src/components/tools/fence/fence-tool.tsx index 5afa67893..f03cbd6b0 100644 --- a/packages/editor/src/components/tools/fence/fence-tool.tsx +++ b/packages/editor/src/components/tools/fence/fence-tool.tsx @@ -1,23 +1,29 @@ import { + calculateLevelMiters, emitter, type FenceNode, type GridEvent, + getWallMiterBoundaryPoints, type LevelNode, + type Point2D, useScene, + type WallMiterData, type WallNode, } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { Html } from '@react-three/drei' -import { useEffect, useRef, useState } from 'react' -import { DoubleSide, type Group, type Mesh, Shape, ShapeGeometry, Vector3 } from 'three' +import { useEffect, useMemo, useRef, useState } from 'react' +import { BoxGeometry, BufferGeometry, DoubleSide, type Group, type Mesh, Vector3 } from 'three' import { markToolCancelConsumed } from '../../../hooks/use-keyboard' import { EDITOR_LAYER } from '../../../lib/constants' import { sfxEmitter } from '../../../lib/sfx-bus' import { CursorSphere } from '../shared/cursor-sphere' import { formatAngleRadians, + getAngleArcToSegmentReference, getAngleToSegmentReference, getSegmentAngleReferenceAtPoint, + type SegmentAngleReference, } from '../shared/segment-angle' import { createFenceOnCurrentLevel, @@ -26,13 +32,25 @@ import { } from './fence-drafting' const FENCE_PREVIEW_HEIGHT = 1.8 +const FENCE_PREVIEW_THICKNESS = 0.08 const DRAFT_LABEL_Y = FENCE_PREVIEW_HEIGHT + 0.22 -const DRAFT_ANGLE_LABEL_Y = 0.28 +const DRAFT_ANGLE_LABEL_Y = FENCE_PREVIEW_HEIGHT + 0.08 +const DRAFT_ANGLE_ARC_Y = FENCE_PREVIEW_HEIGHT + 0.012 +const DRAFT_ANGLE_ARC_MIN_RADIUS = 0.32 +const DRAFT_ANGLE_ARC_MAX_RADIUS = 0.72 +const DRAFT_ANGLE_ARC_SEGMENTS = 24 type DraftAngleLabel = { id: string label: string position: [number, number, number] + arc: { + center: FencePlanPoint + radius: number + startAngle: number + endAngle: number + y: number + } } type DraftMeasurementState = { @@ -46,6 +64,25 @@ type SegmentLike = { start: FencePlanPoint end: FencePlanPoint curveOffset?: number + thickness?: number +} + +type FaceAngleCandidate = { + index: number + point: FencePlanPoint + vector: FencePlanPoint +} + +type FaceAnglePair = { + draft: FaceAngleCandidate + connected: FaceAngleCandidate + distance: number +} + +type AngleSource = { + arcCenter: FencePlanPoint + connectedVector: FencePlanPoint + draftVector: FencePlanPoint } function formatMeasurement(value: number, unit: 'metric' | 'imperial') { @@ -60,13 +97,183 @@ function formatMeasurement(value: number, unit: 'metric' | 'imperial') { return `${Number.parseFloat(value.toFixed(2))}m` } +function clamp(value: number, min: number, max: number) { + return Math.min(max, Math.max(min, value)) +} + +function distanceSquared(a: FencePlanPoint, b: FencePlanPoint) { + const dx = a[0] - b[0] + const dz = a[1] - b[1] + + return dx * dx + dz * dz +} + +function pointMatches(a: FencePlanPoint, b: FencePlanPoint, tolerance = 1e-5) { + return distanceSquared(a, b) <= tolerance * tolerance +} + +function toFencePlanPoint(point: Point2D): FencePlanPoint { + return [point.x, point.y] +} + +function toMiterWall(segment: SegmentLike): WallNode { + return { + object: 'node', + id: segment.id as WallNode['id'], + type: 'wall', + name: 'Fence reference', + parentId: null, + visible: true, + metadata: {}, + children: [], + start: segment.start, + end: segment.end, + thickness: segment.thickness, + curveOffset: segment.curveOffset, + frontSide: 'unknown', + backSide: 'unknown', + } +} + +function buildDraftFenceSegment(start: FencePlanPoint, end: FencePlanPoint): SegmentLike { + return { + id: 'fence_draft', + start, + end, + thickness: FENCE_PREVIEW_THICKNESS, + } +} + +function getSegmentEndpointKind( + point: FencePlanPoint, + segment: SegmentLike, +): 'start' | 'end' | null { + if (pointMatches(point, segment.start)) return 'start' + if (pointMatches(point, segment.end)) return 'end' + + return null +} + +function getFenceFaceAngleCandidates( + point: FencePlanPoint, + segment: SegmentLike, + miterData: WallMiterData, +): FaceAngleCandidate[] { + const endpoint = getSegmentEndpointKind(point, segment) + const reference = getSegmentAngleReferenceAtPoint(point, segment) + if (!(endpoint && reference)) return [] + + const boundaryPoints = getWallMiterBoundaryPoints(toMiterWall(segment), miterData) + if (!boundaryPoints) return [] + + const points = + endpoint === 'start' + ? [boundaryPoints.startLeft, boundaryPoints.startRight] + : [boundaryPoints.endLeft, boundaryPoints.endRight] + + return points.map((facePoint, index) => ({ + index, + point: toFencePlanPoint(facePoint), + vector: reference.vector, + })) +} + +function getMatchingFaceAnglePairs( + draftCandidates: FaceAngleCandidate[], + connectedCandidates: FaceAngleCandidate[], +) { + const candidates: FaceAnglePair[] = [] + + for (const draftCandidate of draftCandidates) { + for (const connectedCandidate of connectedCandidates) { + candidates.push({ + draft: draftCandidate, + connected: connectedCandidate, + distance: distanceSquared(draftCandidate.point, connectedCandidate.point), + }) + } + } + + candidates.sort((a, b) => a.distance - b.distance) + + const exactPairs = candidates.filter((pair) => pair.distance <= 1e-6) + const sourcePairs = exactPairs.length > 0 ? exactPairs : candidates.slice(0, 1) + const usedDraftIndexes = new Set() + const usedConnectedIndexes = new Set() + const pairs: FaceAnglePair[] = [] + + for (const pair of sourcePairs) { + if (usedDraftIndexes.has(pair.draft.index) || usedConnectedIndexes.has(pair.connected.index)) { + continue + } + + usedDraftIndexes.add(pair.draft.index) + usedConnectedIndexes.add(pair.connected.index) + pairs.push(pair) + + if (pairs.length === 2) break + } + + return pairs +} + +function getAngleSource( + endpointPoint: FencePlanPoint, + endpointDraftVector: FencePlanPoint, + connectedReference: SegmentAngleReference, + facePairs: FaceAnglePair[], +): AngleSource { + if (facePairs.length === 0) { + return { + arcCenter: endpointPoint, + connectedVector: connectedReference.vector, + draftVector: endpointDraftVector, + } + } + + const arc = getAngleArcToSegmentReference(endpointDraftVector, connectedReference) + const angleDirection: FencePlanPoint = arc + ? [Math.cos(arc.midAngle), Math.sin(arc.midAngle)] + : [endpointDraftVector[0], endpointDraftVector[1]] + const bestPair = + facePairs + .map((pair) => { + const arcCenter: FencePlanPoint = [ + (pair.draft.point[0] + pair.connected.point[0]) / 2, + (pair.draft.point[1] + pair.connected.point[1]) / 2, + ] + const fromEndpoint: FencePlanPoint = [ + arcCenter[0] - endpointPoint[0], + arcCenter[1] - endpointPoint[1], + ] + + return { + pair, + score: fromEndpoint[0] * angleDirection[0] + fromEndpoint[1] * angleDirection[1], + } + }) + .sort((a, b) => b.score - a.score)[0]?.pair ?? facePairs[0]! + + return { + arcCenter: [ + (bestPair.draft.point[0] + bestPair.connected.point[0]) / 2, + (bestPair.draft.point[1] + bestPair.connected.point[1]) / 2, + ], + connectedVector: bestPair.connected.vector, + draftVector: bestPair.draft.vector, + } +} + function getDraftAngleLabels( start: FencePlanPoint, end: FencePlanPoint, segments: SegmentLike[], + baseY: number, ): DraftAngleLabel[] { const draftFromStart: FencePlanPoint = [end[0] - start[0], end[1] - start[1]] const draftFromEnd: FencePlanPoint = [start[0] - end[0], start[1] - end[1]] + const draftSegment = buildDraftFenceSegment(start, end) + const miterData = calculateLevelMiters([...segments, draftSegment].map(toMiterWall)) const endpoints = [ { id: 'start', point: start, draftVector: draftFromStart }, { id: 'end', point: end, draftVector: draftFromEnd }, @@ -82,13 +289,52 @@ function getDraftAngleLabels( const connectedReference = getSegmentAngleReferenceAtPoint(endpoint.point, connectedSegment) if (!connectedReference) continue - const angle = getAngleToSegmentReference(endpoint.draftVector, connectedReference) + const draftFaceCandidates = getFenceFaceAngleCandidates(endpoint.point, draftSegment, miterData) + const connectedFaceCandidates = getFenceFaceAngleCandidates( + endpoint.point, + connectedSegment, + miterData, + ) + const facePairs = getMatchingFaceAnglePairs(draftFaceCandidates, connectedFaceCandidates) + const { arcCenter, connectedVector, draftVector } = getAngleSource( + endpoint.point, + endpoint.draftVector, + connectedReference, + facePairs, + ) + const angle = getAngleToSegmentReference(draftVector, { + ...connectedReference, + vector: connectedVector, + }) if (angle === null) continue + const arc = getAngleArcToSegmentReference(draftVector, { + ...connectedReference, + vector: connectedVector, + }) + if (!arc || arc.angle < 0.01) continue + const draftLength = Math.hypot(draftVector[0], draftVector[1]) + const referenceLength = Math.hypot(connectedVector[0], connectedVector[1]) + const radius = clamp( + Math.min(draftLength, referenceLength) * 0.28, + DRAFT_ANGLE_ARC_MIN_RADIUS, + DRAFT_ANGLE_ARC_MAX_RADIUS, + ) labels.push({ id: endpoint.id, label: formatAngleRadians(angle), - position: [endpoint.point[0], DRAFT_ANGLE_LABEL_Y, endpoint.point[1]], + position: [ + arcCenter[0] + Math.cos(arc.midAngle) * (radius + 0.16), + baseY + DRAFT_ANGLE_LABEL_Y, + arcCenter[1] + Math.sin(arc.midAngle) * (radius + 0.16), + ], + arc: { + center: arcCenter, + radius, + startAngle: arc.startAngle, + endAngle: arc.endAngle, + y: baseY + DRAFT_ANGLE_ARC_Y, + }, }) } @@ -100,6 +346,7 @@ function getDraftMeasurementState( end: FencePlanPoint, segments: SegmentLike[], unit: 'metric' | 'imperial', + baseY: number, ): DraftMeasurementState { const dx = end[0] - start[0] const dz = end[1] - start[1] @@ -109,8 +356,8 @@ function getDraftMeasurementState( return { lengthLabel: formatMeasurement(length, unit), - lengthPosition: [(start[0] + end[0]) / 2, DRAFT_LABEL_Y, (start[1] + end[1]) / 2], - angleLabels: getDraftAngleLabels(start, end, segments), + lengthPosition: [(start[0] + end[0]) / 2, baseY + DRAFT_LABEL_Y, (start[1] + end[1]) / 2], + angleLabels: getDraftAngleLabels(start, end, segments, baseY), } } @@ -121,12 +368,14 @@ function getReferenceSegments(walls: WallNode[], fences: FenceNode[]): SegmentLi start: wall.start, end: wall.end, curveOffset: wall.curveOffset, + thickness: wall.thickness, })), ...fences.map((fence) => ({ id: fence.id, start: fence.start, end: fence.end, curveOffset: fence.curveOffset, + thickness: fence.thickness, })), ] } @@ -143,18 +392,15 @@ const updateFencePreview = (mesh: Mesh, start: Vector3, end: Vector3) => { mesh.visible = true direction.normalize() - const shape = new Shape() - shape.moveTo(0, 0) - shape.lineTo(length, 0) - shape.lineTo(length, FENCE_PREVIEW_HEIGHT) - shape.lineTo(0, FENCE_PREVIEW_HEIGHT) - shape.closePath() - - const geometry = new ShapeGeometry(shape) - const angle = -Math.atan2(direction.z, direction.x) + const geometry = new BoxGeometry(length, FENCE_PREVIEW_HEIGHT, FENCE_PREVIEW_THICKNESS) + const angle = Math.atan2(direction.z, direction.x) - mesh.position.set(start.x, start.y, start.z) - mesh.rotation.y = angle + mesh.position.set( + (start.x + end.x) / 2, + start.y + FENCE_PREVIEW_HEIGHT / 2, + (start.z + end.z) / 2, + ) + mesh.rotation.y = -angle if (mesh.geometry) { mesh.geometry.dispose() @@ -181,6 +427,7 @@ const getCurrentLevelElements = (): { walls: WallNode[]; fences: FenceNode[] } = export const FenceTool: React.FC = () => { const unit = useViewer((state) => state.unit) + const theme = useViewer((state) => state.theme) const cursorRef = useRef(null) const previewRef = useRef(null!) const startingPoint = useRef(new Vector3(0, 0, 0)) @@ -188,10 +435,18 @@ export const FenceTool: React.FC = () => { const buildingState = useRef(0) const shiftPressed = useRef(false) const [draftMeasurement, setDraftMeasurement] = useState(null) + const measurementColor = theme === 'dark' ? '#ffffff' : '#111111' + const measurementShadowColor = theme === 'dark' ? '#111111' : '#ffffff' useEffect(() => { let previousFenceEnd: [number, number] | null = null + const stopDrafting = () => { + buildingState.current = 0 + previewRef.current.visible = false + setDraftMeasurement(null) + } + const onGridMove = (event: GridEvent) => { if (!(cursorRef.current && previewRef.current)) return @@ -225,6 +480,7 @@ export const FenceTool: React.FC = () => { snappedLocal, getReferenceSegments(walls, fences), unit, + startingPoint.current.y, ), ) } else { @@ -235,6 +491,11 @@ export const FenceTool: React.FC = () => { } const onGridClick = (event: GridEvent) => { + if (buildingState.current === 1 && event.nativeEvent.detail >= 2) { + stopDrafting() + return + } + const { walls, fences } = getCurrentLevelElements() const localClick: FencePlanPoint = [event.localPosition[0], event.localPosition[2]] @@ -256,9 +517,18 @@ export const FenceTool: React.FC = () => { const dx = snappedEnd[0] - startingPoint.current.x const dz = snappedEnd[1] - startingPoint.current.z if (dx * dx + dz * dz < 0.01 * 0.01) return - createFenceOnCurrentLevel([startingPoint.current.x, startingPoint.current.z], snappedEnd) + const createdFence = createFenceOnCurrentLevel( + [startingPoint.current.x, startingPoint.current.z], + snappedEnd, + ) + if (!createdFence) return + + const nextStart = createdFence.end + startingPoint.current.set(nextStart[0], event.localPosition[1], nextStart[1]) + endingPoint.current.copy(startingPoint.current) + cursorRef.current.position.copy(startingPoint.current) previewRef.current.visible = false - buildingState.current = 0 + buildingState.current = 1 setDraftMeasurement(null) } } @@ -274,9 +544,7 @@ export const FenceTool: React.FC = () => { const onCancel = () => { if (buildingState.current === 1) { markToolCancelConsumed() - buildingState.current = 0 - previewRef.current.visible = false - setDraftMeasurement(null) + stopDrafting() } } @@ -313,15 +581,21 @@ export const FenceTool: React.FC = () => { {draftMeasurement && ( <> {draftMeasurement.angleLabels.map((angleLabel) => ( - + + + + ))} )} @@ -329,16 +603,67 @@ export const FenceTool: React.FC = () => { ) } +function DraftAngleArc({ arc, color }: { arc: DraftAngleLabel['arc']; color: string }) { + const geometry = useMemo(() => { + const segmentCount = Math.max( + 8, + Math.ceil((Math.abs(arc.endAngle - arc.startAngle) / Math.PI) * DRAFT_ANGLE_ARC_SEGMENTS), + ) + + const points = Array.from({ length: segmentCount + 1 }, (_, index) => { + const t = index / segmentCount + const angle = arc.startAngle + (arc.endAngle - arc.startAngle) * t + + return new Vector3( + arc.center[0] + Math.cos(angle) * arc.radius, + arc.y, + arc.center[1] + Math.sin(angle) * arc.radius, + ) + }) + + return new BufferGeometry().setFromPoints(points) + }, [arc]) + + return ( + // @ts-expect-error - R3F accepts Three line primitives, matching the other editor drawing tools. + + + + ) +} + function DraftMeasurementLabel({ + color, label, position, + shadowColor, }: { + color: string label: string position: [number, number, number] + shadowColor: string }) { return ( - -
+ +
{label}
diff --git a/packages/editor/src/components/tools/fence/move-fence-tool.tsx b/packages/editor/src/components/tools/fence/move-fence-tool.tsx index 789931cee..b49913942 100644 --- a/packages/editor/src/components/tools/fence/move-fence-tool.tsx +++ b/packages/editor/src/components/tools/fence/move-fence-tool.tsx @@ -2,18 +2,18 @@ import { type AnyNodeId, + constrainWallMoveDeltaToAxis, emitter, type FenceNode, type GridEvent, + getPerpendicularWallMoveAxis, type LevelNode, - sceneRegistry, - useLiveTransforms, useScene, + type WallMoveAxis, type WallNode, } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { useCallback, useEffect, useRef, useState } from 'react' -import type * as THREE from 'three' import { markToolCancelConsumed } from '../../../hooks/use-keyboard' import { sfxEmitter } from '../../../lib/sfx-bus' import useEditor from '../../../store/use-editor' @@ -97,17 +97,28 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => { const previousGridPosRef = useRef<[number, number] | null>(null) const originalStartRef = useRef<[number, number]>([...node.start] as [number, number]) const originalEndRef = useRef<[number, number]>([...node.end] as [number, number]) + const meta = + typeof node.metadata === 'object' && node.metadata !== null && !Array.isArray(node.metadata) + ? (node.metadata as Record) + : {} + const isNew = !!meta.isNew + const linkedOriginalsRef = useRef( - getLinkedFenceSnapshots({ - fenceId: node.id, - fenceParentId: node.parentId ?? null, - originalStart: node.start, - originalEnd: node.end, - }), + isNew + ? [] + : getLinkedFenceSnapshots({ + fenceId: node.id, + fenceParentId: node.parentId ?? null, + originalStart: node.start, + originalEnd: node.end, + }), ) const dragAnchorRef = useRef<[number, number] | null>(null) const nodeIdRef = useRef(node.id) const previewRef = useRef<{ start: [number, number]; end: [number, number] } | null>(null) + const moveAxisRef = useRef( + getPerpendicularWallMoveAxis(node.start, node.end), + ) const [cursorLocalPos, setCursorLocalPos] = useState<[number, number, number]>(() => { const centerX = (node.start[0] + node.end[0]) / 2 @@ -138,32 +149,11 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => { useScene.temporal.getState().pause() let wasCommitted = false - const setMeshOffset = (fenceId: FenceNode['id'], deltaX: number, deltaZ: number) => { - const mesh = sceneRegistry.nodes.get(fenceId) as THREE.Object3D | undefined - if (!mesh) { - return - } - - mesh.position.set(deltaX, 0, deltaZ) - } - - const setFenceLiveTransform = (fence: FenceNode, deltaX: number, deltaZ: number) => { - const originalCenterX = (fence.start[0] + fence.end[0]) / 2 - const originalCenterZ = (fence.start[1] + fence.end[1]) / 2 - useLiveTransforms.getState().set(fence.id, { - position: [originalCenterX + deltaX, 0, originalCenterZ + deltaZ], - rotation: 0, - }) - } - - const clearPreviewState = () => { - setMeshOffset(nodeId, 0, 0) - useLiveTransforms.getState().clear(nodeId) - - for (const linkedFence of linkedOriginalsRef.current) { - setMeshOffset(linkedFence.id, 0, 0) - useLiveTransforms.getState().clear(linkedFence.id) - } + const restoreOriginal = () => { + applyNodePreview([ + { id: nodeId, start: originalStart, end: originalEnd }, + ...linkedOriginalsRef.current, + ]) } const applyNodePreview = ( @@ -185,24 +175,18 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => { const centerX = (nextStart[0] + nextEnd[0]) / 2 const centerZ = (nextStart[1] + nextEnd[1]) / 2 setCursorLocalPos([centerX, 0, centerZ]) - const deltaX = nextStart[0] - originalStart[0] - const deltaZ = nextStart[1] - originalStart[1] - setMeshOffset(nodeId, deltaX, deltaZ) - setFenceLiveTransform(node, deltaX, deltaZ) - - for (const linkedFence of linkedOriginalsRef.current) { - setMeshOffset(linkedFence.id, deltaX, deltaZ) - setFenceLiveTransform( - { - ...node, - id: linkedFence.id, - start: linkedFence.start, - end: linkedFence.end, - }, - deltaX, - deltaZ, - ) - } + const previewUpdates = [ + { id: nodeId, start: nextStart, end: nextEnd }, + ...getLinkedFenceUpdates( + linkedOriginalsRef.current, + originalStart, + originalEnd, + nextStart, + nextEnd, + ), + ] + + applyNodePreview(previewUpdates) } const onGridMove = (event: GridEvent) => { @@ -224,8 +208,11 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => { const anchor = dragAnchorRef.current ?? [localX, localZ] dragAnchorRef.current = anchor - const deltaX = localX - anchor[0] - const deltaZ = localZ - anchor[1] + const [deltaX, deltaZ] = constrainWallMoveDeltaToAxis( + localX - anchor[0], + localZ - anchor[1], + moveAxisRef.current, + ) const nextStart: [number, number] = [originalStart[0] + deltaX, originalStart[1] + deltaZ] const nextEnd: [number, number] = [originalEnd[0] + deltaX, originalEnd[1] + deltaZ] @@ -243,6 +230,10 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => { wasCommitted = true + // Restore original baseline while paused so the next resume+update + // registers as a single tracked change (undo reverts to original). + restoreOriginal() + useScene.temporal.getState().resume() applyNodePreview([ { id: nodeId, start: preview.start, end: preview.end }, @@ -254,10 +245,6 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => { preview.end, ), ]) - useLiveTransforms.getState().clear(nodeId) - for (const linkedFence of linkedOriginalsRef.current) { - useLiveTransforms.getState().clear(linkedFence.id) - } useScene.temporal.getState().pause() sfxEmitter.emit('sfx:item-place') @@ -267,7 +254,7 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => { } const onCancel = () => { - clearPreviewState() + restoreOriginal() useViewer.getState().setSelection({ selectedIds: [nodeId] }) useScene.temporal.getState().resume() markToolCancelConsumed() @@ -279,13 +266,8 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => { emitter.on('tool:cancel', onCancel) return () => { - if (wasCommitted) { - useLiveTransforms.getState().clear(nodeId) - for (const linkedFence of linkedOriginalsRef.current) { - useLiveTransforms.getState().clear(linkedFence.id) - } - } else { - clearPreviewState() + if (!wasCommitted) { + restoreOriginal() } useScene.temporal.getState().resume() emitter.off('grid:move', onGridMove) diff --git a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx index fdafe3635..6582fc1c3 100644 --- a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx +++ b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx @@ -285,6 +285,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea const basePlaneRef = useRef(null!) const gridPosition = useRef(new Vector3(0, 0, 0)) const lastRawPos = useRef(new Vector3(0, 0, 0)) + const lastWallDirtyAtRef = useRef(new Map()) const placementState = useRef( config.initialState ?? { surface: 'floor', wallId: null, ceilingId: null, surfaceItemId: null }, ) @@ -722,7 +723,13 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea } // Mark parent wall dirty so it rebuilds geometry — only when position changed if (result.dirtyNodeId && posChanged) { - useScene.getState().dirtyNodes.add(result.dirtyNodeId) + const now = globalThis.performance?.now?.() ?? Date.now() + const last = lastWallDirtyAtRef.current.get(result.dirtyNodeId) ?? 0 + // Wall rebuilds can trigger expensive CSG; throttle live previews to avoid FPS collapse. + if (now - last > 120) { + lastWallDirtyAtRef.current.set(result.dirtyNodeId, now) + useScene.getState().dirtyNodes.add(result.dirtyNodeId) + } } // Publish live transform for 2D floorplan diff --git a/packages/editor/src/components/tools/shared/segment-angle.ts b/packages/editor/src/components/tools/shared/segment-angle.ts index 0e39a5707..d72cfcde8 100644 --- a/packages/editor/src/components/tools/shared/segment-angle.ts +++ b/packages/editor/src/components/tools/shared/segment-angle.ts @@ -15,6 +15,13 @@ export type SegmentAngleReference = { orientation: 'directed' | 'axis' } +export type SegmentAngleArc = { + angle: number + startAngle: number + endAngle: number + midAngle: number +} + const POINT_MATCH_TOLERANCE = 1e-5 const SEGMENT_POINT_TOLERANCE = 0.15 const CURVE_TANGENT_SAMPLE_SPACING = 0.08 @@ -92,6 +99,36 @@ export function getAngleBetweenVectors(first: PlanPoint, second: PlanPoint): num return Math.acos(cosine) } +function normalizeSignedAngle(angle: number) { + let nextAngle = angle + + while (nextAngle <= -Math.PI) { + nextAngle += Math.PI * 2 + } + + while (nextAngle > Math.PI) { + nextAngle -= Math.PI * 2 + } + + return nextAngle +} + +function getSignedAngleArc(vector: PlanPoint, referenceVector: PlanPoint): SegmentAngleArc | null { + const angle = getAngleBetweenVectors(vector, referenceVector) + if (angle === null) return null + + const startAngle = Math.atan2(referenceVector[1], referenceVector[0]) + const vectorAngle = Math.atan2(vector[1], vector[0]) + const signedDelta = normalizeSignedAngle(vectorAngle - startAngle) + + return { + angle: Math.abs(signedDelta), + startAngle, + endAngle: startAngle + signedDelta, + midAngle: startAngle + signedDelta / 2, + } +} + export function getAngleToSegmentReference( vector: PlanPoint, reference: SegmentAngleReference, @@ -111,6 +148,25 @@ export function getAngleToSegmentReference( return Math.min(angle, reverseAngle) } +export function getAngleArcToSegmentReference( + vector: PlanPoint, + reference: SegmentAngleReference, +): SegmentAngleArc | null { + const directArc = getSignedAngleArc(vector, reference.vector) + + if (!directArc || reference.orientation === 'directed') { + return directArc + } + + const reverseArc = getSignedAngleArc(vector, [-reference.vector[0], -reference.vector[1]]) + + if (!reverseArc) { + return directArc + } + + return reverseArc.angle < directArc.angle ? reverseArc : directArc +} + export function getSegmentAngleReferenceAtPoint( point: PlanPoint, segment: SegmentAngleLike, diff --git a/packages/editor/src/components/tools/stair/stair-tool.tsx b/packages/editor/src/components/tools/stair/stair-tool.tsx index 73d033cc4..c82711b9a 100644 --- a/packages/editor/src/components/tools/stair/stair-tool.tsx +++ b/packages/editor/src/components/tools/stair/stair-tool.tsx @@ -1,5 +1,4 @@ import { - type AnyNode, emitter, type GridEvent, type LevelNode, diff --git a/packages/editor/src/components/tools/wall/move-wall-tool.tsx b/packages/editor/src/components/tools/wall/move-wall-tool.tsx index 36cc061b5..accfce33d 100644 --- a/packages/editor/src/components/tools/wall/move-wall-tool.tsx +++ b/packages/editor/src/components/tools/wall/move-wall-tool.tsx @@ -242,10 +242,7 @@ function getLevelSlabs(levelId: string, nodes: ReturnType['nodes'], -) { +function getLevelAutoSlabs(levelId: string, nodes: ReturnType['nodes']) { return getLevelSlabs(levelId, nodes).filter((slab) => slab.autoFromWalls) } diff --git a/packages/editor/src/components/tools/wall/wall-tool.tsx b/packages/editor/src/components/tools/wall/wall-tool.tsx index 0cec767f1..2ec3d2405 100644 --- a/packages/editor/src/components/tools/wall/wall-tool.tsx +++ b/packages/editor/src/components/tools/wall/wall-tool.tsx @@ -1,27 +1,51 @@ -import { emitter, type GridEvent, type LevelNode, useScene, type WallNode } from '@pascal-app/core' +import { + calculateLevelMiters, + emitter, + type GridEvent, + getWallMiterBoundaryPoints, + type LevelNode, + type Point2D, + useScene, + type WallMiterData, + type WallNode, +} from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { Html } from '@react-three/drei' -import { useEffect, useRef, useState } from 'react' -import { DoubleSide, type Group, type Mesh, Shape, ShapeGeometry, Vector3 } from 'three' +import { useEffect, useMemo, useRef, useState } from 'react' +import { BoxGeometry, BufferGeometry, DoubleSide, type Group, type Mesh, Vector3 } from 'three' import { markToolCancelConsumed } from '../../../hooks/use-keyboard' import { EDITOR_LAYER } from '../../../lib/constants' import { sfxEmitter } from '../../../lib/sfx-bus' import { CursorSphere } from '../shared/cursor-sphere' import { formatAngleRadians, + getAngleArcToSegmentReference, getAngleToSegmentReference, getSegmentAngleReferenceAtPoint, + type SegmentAngleReference, } from '../shared/segment-angle' import { createWallOnCurrentLevel, snapWallDraftPoint, type WallPlanPoint } from './wall-drafting' const WALL_HEIGHT = 2.5 +const DRAFT_WALL_THICKNESS = 0.1 const DRAFT_LABEL_Y = WALL_HEIGHT + 0.22 -const DRAFT_ANGLE_LABEL_Y = 0.28 +const DRAFT_ANGLE_LABEL_Y = WALL_HEIGHT + 0.08 +const DRAFT_ANGLE_ARC_Y = WALL_HEIGHT + 0.012 +const DRAFT_ANGLE_ARC_MIN_RADIUS = 0.32 +const DRAFT_ANGLE_ARC_MAX_RADIUS = 0.72 +const DRAFT_ANGLE_ARC_SEGMENTS = 24 type DraftAngleLabel = { id: string label: string position: [number, number, number] + arc: { + center: WallPlanPoint + radius: number + startAngle: number + endAngle: number + y: number + } } type DraftMeasurementState = { @@ -30,6 +54,24 @@ type DraftMeasurementState = { angleLabels: DraftAngleLabel[] } | null +type FaceAngleCandidate = { + index: number + point: WallPlanPoint + vector: WallPlanPoint +} + +type FaceAnglePair = { + draft: FaceAngleCandidate + connected: FaceAngleCandidate + distance: number +} + +type AngleSource = { + arcCenter: WallPlanPoint + connectedVector: WallPlanPoint + draftVector: WallPlanPoint +} + function formatMeasurement(value: number, unit: 'metric' | 'imperial') { if (unit === 'imperial') { const feet = value * 3.280_84 @@ -42,13 +84,171 @@ function formatMeasurement(value: number, unit: 'metric' | 'imperial') { return `${Number.parseFloat(value.toFixed(2))}m` } +function clamp(value: number, min: number, max: number) { + return Math.min(max, Math.max(min, value)) +} + +function distanceSquared(a: WallPlanPoint, b: WallPlanPoint) { + const dx = a[0] - b[0] + const dz = a[1] - b[1] + + return dx * dx + dz * dz +} + +function pointMatches(a: WallPlanPoint, b: WallPlanPoint, tolerance = 1e-5) { + return distanceSquared(a, b) <= tolerance * tolerance +} + +function toWallPlanPoint(point: Point2D): WallPlanPoint { + return [point.x, point.y] +} + +function getWallEndpointKind(point: WallPlanPoint, wall: WallNode): 'start' | 'end' | null { + if (pointMatches(point, wall.start)) return 'start' + if (pointMatches(point, wall.end)) return 'end' + + return null +} + +function buildDraftWall(start: WallPlanPoint, end: WallPlanPoint): WallNode { + return { + object: 'node', + id: 'wall_draft' as WallNode['id'], + type: 'wall', + name: 'Draft wall', + parentId: null, + visible: true, + metadata: {}, + children: [], + start, + end, + thickness: DRAFT_WALL_THICKNESS, + frontSide: 'unknown', + backSide: 'unknown', + } +} + +function getWallFaceAngleCandidates( + point: WallPlanPoint, + wall: WallNode, + miterData: WallMiterData, +): FaceAngleCandidate[] { + const endpoint = getWallEndpointKind(point, wall) + const reference = getSegmentAngleReferenceAtPoint(point, wall) + if (!(endpoint && reference)) return [] + + const boundaryPoints = getWallMiterBoundaryPoints(wall, miterData) + if (!boundaryPoints) return [] + + const points = + endpoint === 'start' + ? [boundaryPoints.startLeft, boundaryPoints.startRight] + : [boundaryPoints.endLeft, boundaryPoints.endRight] + + return points.map((facePoint, index) => ({ + index, + point: toWallPlanPoint(facePoint), + vector: reference.vector, + })) +} + +function getMatchingFaceAnglePairs( + draftCandidates: FaceAngleCandidate[], + connectedCandidates: FaceAngleCandidate[], +) { + const candidates: FaceAnglePair[] = [] + + for (const draftCandidate of draftCandidates) { + for (const connectedCandidate of connectedCandidates) { + candidates.push({ + draft: draftCandidate, + connected: connectedCandidate, + distance: distanceSquared(draftCandidate.point, connectedCandidate.point), + }) + } + } + + candidates.sort((a, b) => a.distance - b.distance) + + const exactPairs = candidates.filter((pair) => pair.distance <= 1e-6) + const sourcePairs = exactPairs.length > 0 ? exactPairs : candidates.slice(0, 1) + const usedDraftIndexes = new Set() + const usedConnectedIndexes = new Set() + const pairs: FaceAnglePair[] = [] + + for (const pair of sourcePairs) { + if (usedDraftIndexes.has(pair.draft.index) || usedConnectedIndexes.has(pair.connected.index)) { + continue + } + + usedDraftIndexes.add(pair.draft.index) + usedConnectedIndexes.add(pair.connected.index) + pairs.push(pair) + + if (pairs.length === 2) break + } + + return pairs +} + +function getAngleSource( + endpointPoint: WallPlanPoint, + endpointDraftVector: WallPlanPoint, + connectedReference: SegmentAngleReference, + facePairs: FaceAnglePair[], +): AngleSource { + if (facePairs.length === 0) { + return { + arcCenter: endpointPoint, + connectedVector: connectedReference.vector, + draftVector: endpointDraftVector, + } + } + + const arc = getAngleArcToSegmentReference(endpointDraftVector, connectedReference) + const angleDirection: WallPlanPoint = arc + ? [Math.cos(arc.midAngle), Math.sin(arc.midAngle)] + : [endpointDraftVector[0], endpointDraftVector[1]] + const bestPair = + facePairs + .map((pair) => { + const arcCenter: WallPlanPoint = [ + (pair.draft.point[0] + pair.connected.point[0]) / 2, + (pair.draft.point[1] + pair.connected.point[1]) / 2, + ] + const fromEndpoint: WallPlanPoint = [ + arcCenter[0] - endpointPoint[0], + arcCenter[1] - endpointPoint[1], + ] + + return { + arcCenter, + pair, + score: fromEndpoint[0] * angleDirection[0] + fromEndpoint[1] * angleDirection[1], + } + }) + .sort((a, b) => b.score - a.score)[0]?.pair ?? facePairs[0]! + + return { + arcCenter: [ + (bestPair.draft.point[0] + bestPair.connected.point[0]) / 2, + (bestPair.draft.point[1] + bestPair.connected.point[1]) / 2, + ], + connectedVector: bestPair.connected.vector, + draftVector: bestPair.draft.vector, + } +} + function getDraftAngleLabels( start: WallPlanPoint, end: WallPlanPoint, walls: WallNode[], + baseY: number, ): DraftAngleLabel[] { const draftFromStart: WallPlanPoint = [end[0] - start[0], end[1] - start[1]] const draftFromEnd: WallPlanPoint = [start[0] - end[0], start[1] - end[1]] + const draftWall = buildDraftWall(start, end) + const miterData = calculateLevelMiters([...walls, draftWall]) const endpoints = [ { id: 'start', point: start, draftVector: draftFromStart }, { id: 'end', point: end, draftVector: draftFromEnd }, @@ -64,13 +264,51 @@ function getDraftAngleLabels( const connectedReference = getSegmentAngleReferenceAtPoint(endpoint.point, connectedWall) if (!connectedReference) continue - const angle = getAngleToSegmentReference(endpoint.draftVector, connectedReference) + const draftFaceCandidates = getWallFaceAngleCandidates(endpoint.point, draftWall, miterData) + const connectedFaceCandidates = getWallFaceAngleCandidates( + endpoint.point, + connectedWall, + miterData, + ) + const facePairs = getMatchingFaceAnglePairs(draftFaceCandidates, connectedFaceCandidates) + const { arcCenter, connectedVector, draftVector } = getAngleSource( + endpoint.point, + endpoint.draftVector, + connectedReference, + facePairs, + ) + const angle = getAngleToSegmentReference(draftVector, { + ...connectedReference, + vector: connectedVector, + }) if (angle === null) continue - + const arc = getAngleArcToSegmentReference(draftVector, { + ...connectedReference, + vector: connectedVector, + }) + if (!arc || arc.angle < 0.01) continue + const draftLength = Math.hypot(draftVector[0], draftVector[1]) + const referenceLength = Math.hypot(connectedVector[0], connectedVector[1]) + const radius = clamp( + Math.min(draftLength, referenceLength) * 0.28, + DRAFT_ANGLE_ARC_MIN_RADIUS, + DRAFT_ANGLE_ARC_MAX_RADIUS, + ) labels.push({ id: endpoint.id, label: formatAngleRadians(angle), - position: [endpoint.point[0], DRAFT_ANGLE_LABEL_Y, endpoint.point[1]], + position: [ + arcCenter[0] + Math.cos(arc.midAngle) * (radius + 0.16), + baseY + DRAFT_ANGLE_LABEL_Y, + arcCenter[1] + Math.sin(arc.midAngle) * (radius + 0.16), + ], + arc: { + center: arcCenter, + radius, + startAngle: arc.startAngle, + endAngle: arc.endAngle, + y: baseY + DRAFT_ANGLE_ARC_Y, + }, }) } @@ -82,6 +320,7 @@ function getDraftMeasurementState( end: WallPlanPoint, walls: WallNode[], unit: 'metric' | 'imperial', + baseY: number, ): DraftMeasurementState { const dx = end[0] - start[0] const dz = end[1] - start[1] @@ -91,16 +330,12 @@ function getDraftMeasurementState( return { lengthLabel: formatMeasurement(length, unit), - lengthPosition: [(start[0] + end[0]) / 2, DRAFT_LABEL_Y, (start[1] + end[1]) / 2], - angleLabels: getDraftAngleLabels(start, end, walls), + lengthPosition: [(start[0] + end[0]) / 2, baseY + DRAFT_LABEL_Y, (start[1] + end[1]) / 2], + angleLabels: getDraftAngleLabels(start, end, walls, baseY), } } -/** - * Update wall preview mesh geometry to create a vertical plane between two points - */ const updateWallPreview = (mesh: Mesh, start: Vector3, end: Vector3) => { - // Calculate direction and perpendicular for wall thickness const direction = new Vector3(end.x - start.x, 0, end.z - start.z) const length = direction.length() @@ -112,26 +347,12 @@ const updateWallPreview = (mesh: Mesh, start: Vector3, end: Vector3) => { mesh.visible = true direction.normalize() - // Create wall shape (vertical rectangle in XY plane) - const shape = new Shape() - shape.moveTo(0, 0) - shape.lineTo(length, 0) - shape.lineTo(length, WALL_HEIGHT) - shape.lineTo(0, WALL_HEIGHT) - shape.closePath() + const geometry = new BoxGeometry(length, WALL_HEIGHT, DRAFT_WALL_THICKNESS) + const angle = Math.atan2(direction.z, direction.x) - // Create geometry - const geometry = new ShapeGeometry(shape) + mesh.position.set((start.x + end.x) / 2, start.y + WALL_HEIGHT / 2, (start.z + end.z) / 2) + mesh.rotation.y = -angle - // Calculate rotation angle - // Negate the angle to fix the opposite direction issue - const angle = -Math.atan2(direction.z, direction.x) - - // Position at start point and rotate - mesh.position.set(start.x, start.y, start.z) - mesh.rotation.y = angle - - // Dispose old geometry and assign new one if (mesh.geometry) { mesh.geometry.dispose() } @@ -154,6 +375,7 @@ const getCurrentLevelWalls = (): WallNode[] => { export const WallTool: React.FC = () => { const unit = useViewer((state) => state.unit) + const theme = useViewer((state) => state.theme) const cursorRef = useRef(null) const wallPreviewRef = useRef(null!) // All positions are building-local: this tool is inside the ToolManager building group, @@ -163,11 +385,19 @@ export const WallTool: React.FC = () => { const buildingState = useRef(0) const shiftPressed = useRef(false) const [draftMeasurement, setDraftMeasurement] = useState(null) + const measurementColor = theme === 'dark' ? '#ffffff' : '#111111' + const measurementShadowColor = theme === 'dark' ? '#111111' : '#ffffff' useEffect(() => { let gridPosition: WallPlanPoint = [0, 0] let previousWallEnd: [number, number] | null = null + const stopDrafting = () => { + buildingState.current = 0 + wallPreviewRef.current.visible = false + setDraftMeasurement(null) + } + const onGridMove = (event: GridEvent) => { if (!(cursorRef.current && wallPreviewRef.current)) return @@ -203,6 +433,7 @@ export const WallTool: React.FC = () => { snappedLocal, walls, unit, + startingPoint.current.y, ), ) } else { @@ -213,6 +444,11 @@ export const WallTool: React.FC = () => { } const onGridClick = (event: GridEvent) => { + if (buildingState.current === 1 && event.nativeEvent.detail >= 2) { + stopDrafting() + return + } + const walls = getCurrentLevelWalls() const localClick: WallPlanPoint = [event.localPosition[0], event.localPosition[2]] @@ -235,9 +471,18 @@ export const WallTool: React.FC = () => { const dz = snappedEnd[1] - startingPoint.current.z if (dx * dx + dz * dz < 0.01 * 0.01) return // Both start and end are building-local ✓ - createWallOnCurrentLevel([startingPoint.current.x, startingPoint.current.z], snappedEnd) + const createdWall = createWallOnCurrentLevel( + [startingPoint.current.x, startingPoint.current.z], + snappedEnd, + ) + if (!createdWall) return + + const nextStart = createdWall.end + startingPoint.current.set(nextStart[0], event.localPosition[1], nextStart[1]) + endingPoint.current.copy(startingPoint.current) + cursorRef.current.position.copy(startingPoint.current) wallPreviewRef.current.visible = false - buildingState.current = 0 + buildingState.current = 1 setDraftMeasurement(null) } } @@ -257,9 +502,7 @@ export const WallTool: React.FC = () => { const onCancel = () => { if (buildingState.current === 1) { markToolCancelConsumed() - buildingState.current = 0 - wallPreviewRef.current.visible = false - setDraftMeasurement(null) + stopDrafting() } } @@ -299,15 +542,21 @@ export const WallTool: React.FC = () => { {draftMeasurement && ( <> {draftMeasurement.angleLabels.map((angleLabel) => ( - + + + + ))} )} @@ -315,16 +564,67 @@ export const WallTool: React.FC = () => { ) } +function DraftAngleArc({ arc, color }: { arc: DraftAngleLabel['arc']; color: string }) { + const geometry = useMemo(() => { + const segmentCount = Math.max( + 8, + Math.ceil((Math.abs(arc.endAngle - arc.startAngle) / Math.PI) * DRAFT_ANGLE_ARC_SEGMENTS), + ) + + const points = Array.from({ length: segmentCount + 1 }, (_, index) => { + const t = index / segmentCount + const angle = arc.startAngle + (arc.endAngle - arc.startAngle) * t + + return new Vector3( + arc.center[0] + Math.cos(angle) * arc.radius, + arc.y, + arc.center[1] + Math.sin(angle) * arc.radius, + ) + }) + + return new BufferGeometry().setFromPoints(points) + }, [arc]) + + return ( + // @ts-expect-error - R3F accepts Three line primitives, matching the other editor drawing tools. + + + + ) +} + function DraftMeasurementLabel({ + color, label, position, + shadowColor, }: { + color: string label: string position: [number, number, number] + shadowColor: string }) { return ( - -
+ +
{label}
diff --git a/packages/editor/src/components/tools/window/move-window-tool.tsx b/packages/editor/src/components/tools/window/move-window-tool.tsx index a3509708e..869e05c58 100644 --- a/packages/editor/src/components/tools/window/move-window-tool.tsx +++ b/packages/editor/src/components/tools/window/move-window-tool.tsx @@ -70,18 +70,29 @@ export const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWin metadata: movingWindowNode.metadata, } - if (!isNew) { - // Move mode: mark the existing window as transient so it hides while being repositioned - useScene.getState().updateNode(movingWindowNode.id, { - metadata: { ...meta, isTransient: true }, - }) - } + // Mark the moving window as transient so it doesn't intercept wall raycasts while repositioning. + // Without this, duplicates can block `wall:*` events which breaks the cursor box and can cause + // rapid enter/leave churn (triggering expensive wall CSG rebuilds). + useScene.getState().updateNode(movingWindowNode.id, { + metadata: { ...meta, isTransient: true }, + }) let currentWallId: string | null = movingWindowNode.parentId const markWallDirty = (wallId: string | null) => { if (wallId) useScene.getState().dirtyNodes.add(wallId as AnyNodeId) } + const lastWallDirtyAt = new Map() + const markWallDirtyThrottled = (wallId: string | null) => { + if (!wallId) return + const now = globalThis.performance?.now?.() ?? Date.now() + const last = lastWallDirtyAt.get(wallId) ?? 0 + // Wall rebuilds can trigger expensive CSG; throttle live previews to avoid FPS collapse. + if (now - last > 120) { + lastWallDirtyAt.set(wallId, now) + markWallDirty(wallId) + } + } const getLevelId = () => useViewer.getState().selection.levelId const getLevelYOffset = () => { @@ -151,7 +162,7 @@ export const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWin }) if (prevWallId && prevWallId !== event.node.id) markWallDirty(prevWallId) - markWallDirty(event.node.id) + markWallDirtyThrottled(event.node.id) const valid = !hasWallChildOverlap( event.node.id, @@ -224,7 +235,7 @@ export const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWin position: [clampedX, clampedY, 0], rotation: itemRotation, }) - markWallDirty(event.node.id) + markWallDirtyThrottled(event.node.id) const valid = !hasWallChildOverlap( event.node.id, @@ -286,28 +297,20 @@ export const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWin useScene.getState().deleteNode(movingWindowNode.id) useScene.temporal.getState().resume() + const cloned = structuredClone(movingWindowNode) as any + delete cloned.id + if (cloned.metadata && typeof cloned.metadata === 'object') { + delete cloned.metadata.isNew + delete cloned.metadata.isTransient + } + const node = WindowNode.parse({ + ...cloned, position: [clampedX, clampedY, 0], rotation: [0, itemRotation, 0], side, wallId: event.node.id, parentId: event.node.id, - width: movingWindowNode.width, - height: movingWindowNode.height, - windowType: movingWindowNode.windowType, - operationState: movingWindowNode.operationState, - awningDirection: movingWindowNode.awningDirection, - casementStyle: movingWindowNode.casementStyle, - hingesSide: movingWindowNode.hingesSide, - frameThickness: movingWindowNode.frameThickness, - frameDepth: movingWindowNode.frameDepth, - columnRatios: movingWindowNode.columnRatios, - rowRatios: movingWindowNode.rowRatios, - columnDividerThickness: movingWindowNode.columnDividerThickness, - rowDividerThickness: movingWindowNode.rowDividerThickness, - sill: movingWindowNode.sill, - sillDepth: movingWindowNode.sillDepth, - sillThickness: movingWindowNode.sillThickness, }) useScene.getState().createNode(node, event.node.id as AnyNodeId) placedId = node.id diff --git a/packages/editor/src/components/ui/action-menu/structure-tools.tsx b/packages/editor/src/components/ui/action-menu/structure-tools.tsx index 352820c72..fd9830e25 100644 --- a/packages/editor/src/components/ui/action-menu/structure-tools.tsx +++ b/packages/editor/src/components/ui/action-menu/structure-tools.tsx @@ -20,17 +20,17 @@ export type ToolConfig = { export const tools: ToolConfig[] = [ { id: 'wall', iconSrc: '/icons/wall.png', label: 'Wall' }, + { id: 'door', iconSrc: '/icons/door.png', label: 'Door' }, + { id: 'window', iconSrc: '/icons/window.png', label: 'Window' }, + { id: 'stair', iconSrc: '/icons/stairs.png', label: 'Stairs' }, + { id: 'roof', iconSrc: '/icons/roof.png', label: 'Gable Roof' }, + { id: 'fence', iconSrc: '/icons/fence.png', label: 'Fence' }, + { id: 'column', iconSrc: '/icons/column.png', label: 'Column' }, + { id: 'elevator', iconSrc: '/icons/elevator.png', label: 'Elevator' }, // { id: 'room', iconSrc: '/icons/room.png', label: 'Room' }, // { id: 'custom-room', iconSrc: '/icons/custom-room.png', label: 'Custom Room' }, { id: 'slab', iconSrc: '/icons/floor.png', label: 'Slab' }, { id: 'ceiling', iconSrc: '/icons/ceiling.png', label: 'Ceiling' }, - { id: 'column', iconSrc: '/icons/column.png', label: 'Column' }, - { id: 'elevator', iconSrc: '/icons/elevator.svg', label: 'Elevator' }, - { id: 'roof', iconSrc: '/icons/roof.png', label: 'Gable Roof' }, - { id: 'stair', iconSrc: '/icons/stairs.png', label: 'Stairs' }, - { id: 'door', iconSrc: '/icons/door.png', label: 'Door' }, - { id: 'window', iconSrc: '/icons/window.png', label: 'Window' }, - { id: 'fence', iconSrc: '/icons/fence.png', label: 'Fence' }, { id: 'zone', iconSrc: '/icons/zone.png', label: 'Zone' }, { id: 'spawn', iconSrc: '/icons/site.png', label: 'Spawn Point' }, ] diff --git a/packages/editor/src/components/ui/controls/material-picker.tsx b/packages/editor/src/components/ui/controls/material-picker.tsx index 91bff54d6..b9918fb24 100644 --- a/packages/editor/src/components/ui/controls/material-picker.tsx +++ b/packages/editor/src/components/ui/controls/material-picker.tsx @@ -22,6 +22,11 @@ type MaterialPickerProps = { hideSideControl?: boolean } +function getCategoryLabel(category: (typeof MATERIAL_CATEGORIES)[number]) { + if (category === 'roof') return 'Roofing' + return category.charAt(0).toUpperCase() + category.slice(1) +} + export function MaterialPicker({ value, selectedMaterialPreset, @@ -156,7 +161,7 @@ export function MaterialPicker({ }} type="button" > - {category.charAt(0).toUpperCase() + category.slice(1)} + {getCategoryLabel(category)} ))}
diff --git a/packages/editor/src/components/ui/panels/door-panel.tsx b/packages/editor/src/components/ui/panels/door-panel.tsx index c46838def..acc959479 100644 --- a/packages/editor/src/components/ui/panels/door-panel.tsx +++ b/packages/editor/src/components/ui/panels/door-panel.tsx @@ -78,6 +78,25 @@ const foldingDoorSegments: DoorNode['segments'] = [ }, ] +const hingedDoorSegments: DoorNode['segments'] = [ + { + type: 'panel', + heightRatio: 0.4, + columnRatios: [1], + dividerThickness: 0.03, + panelDepth: 0.01, + panelInset: 0.04, + }, + { + type: 'panel', + heightRatio: 0.6, + columnRatios: [1], + dividerThickness: 0.03, + panelDepth: 0.01, + panelInset: 0.04, + }, +] + const defaultDoorDimensions: Record = { hinged: { width: 0.9, height: 2.1 }, double: { width: 1.5, height: 2.1 }, @@ -91,6 +110,19 @@ const defaultDoorDimensions: Record = { + hinged: hingedDoorSegments, + double: hingedDoorSegments, + french: frenchDoorSegments, + folding: foldingDoorSegments, + pocket: foldingDoorSegments, + barn: foldingDoorSegments, + sliding: frenchDoorSegments, + 'garage-sectional': foldingDoorSegments, + 'garage-rollup': foldingDoorSegments, + 'garage-tiltup': foldingDoorSegments, +} + function isSameDoorValue(current: unknown, next: unknown): boolean { if (typeof current === 'number' && typeof next === 'number') { return Math.abs(current - next) < 1e-6 @@ -353,16 +385,37 @@ export function DoorPanel() { const openingRevealRadius = node.openingRevealRadius ?? 0.025 const maxRoundedRadius = Math.max(0.01, Math.min(node.width / 2, node.height)) const doorType = node.doorType ?? 'hinged' + const isGarageDoor = node.doorCategory === 'garage' || doorType.startsWith('garage-') const isSwingDoor = doorType === 'hinged' || doorType === 'double' || doorType === 'french' + const isSlideFoldDoor = + doorType === 'folding' || + doorType === 'pocket' || + doorType === 'barn' || + doorType === 'sliding' const isSlidingDoor = doorType === 'pocket' || doorType === 'barn' || doorType === 'sliding' - const isGarageDoor = node.doorCategory === 'garage' || doorType.startsWith('garage-') + const isFoldingDoor = doorType === 'folding' const isSectionalGarageDoor = doorType === 'garage-sectional' const isRollupGarageDoor = doorType === 'garage-rollup' const isTiltupGarageDoor = doorType === 'garage-tiltup' - const typeMode = isOpening ? 'opening' : isGarageDoor ? 'garage' : 'door' + const isCutoutOnly = isOpening + const typeMode = isCutoutOnly ? 'opening' : isGarageDoor ? 'garage' : 'door' const supportsHingeSide = doorType === 'hinged' const supportsHandleSide = doorType === 'hinged' - const supportsTopShape = !isGarageDoor + const supportsTopShape = isSwingDoor + const showFlipSide = !isCutoutOnly + const showFoldSection = isFoldingDoor && !isCutoutOnly + const showSlideSection = isSlidingDoor && !isCutoutOnly + const showGarageSection = + (isSectionalGarageDoor || isRollupGarageDoor || isTiltupGarageDoor) && !isCutoutOnly + const showOpeningShapeSection = isCutoutOnly + const showDoorShapeSection = !isCutoutOnly && supportsTopShape + const showFrameSection = !isCutoutOnly + const showContentPaddingSection = !isCutoutOnly && !isGarageDoor + const showSwingSection = isSwingDoor + const showThresholdSection = isSwingDoor + const showHandleSection = isSwingDoor + const showHardwareSection = isSwingDoor + const showSegmentsSection = !isCutoutOnly && !isGarageDoor const maxDoorWidth = isGarageDoor ? 6 : 3 const setOpeningTopRadius = (index: number, value: number, commit = false) => { @@ -377,6 +430,7 @@ export function DoorPanel() { const getDoorTypeUpdates = (nextDoorType: DoorNode['doorType']): Partial => { const dimensions = defaultDoorDimensions[nextDoorType] + const segments = structuredClone(defaultDoorSegmentsByType[nextDoorType]) const dimensionUpdates = { width: dimensions.width, height: dimensions.height, @@ -390,10 +444,10 @@ export function DoorPanel() { leafCount: 2, ...dimensionUpdates, handleSide: 'right', + segments, ...(nextDoorType === 'french' ? { contentPadding: [0.045, 0.055], - segments: frenchDoorSegments, } : {}), } @@ -405,13 +459,14 @@ export function DoorPanel() { doorType: nextDoorType, leafCount: 4, ...dimensionUpdates, + openingShape: 'rectangle', handle: true, handleSide: 'right', trackStyle: 'visible', operationState: Math.max(node.operationState ?? 0, 0.65), threshold: false, contentPadding: [0.03, 0.04], - segments: foldingDoorSegments, + segments, } } @@ -421,6 +476,7 @@ export function DoorPanel() { doorType: nextDoorType, leafCount: 1, ...dimensionUpdates, + openingShape: 'rectangle', handle: true, handleSide: 'right', trackStyle: 'pocket', @@ -428,7 +484,7 @@ export function DoorPanel() { operationState: node.operationState ?? 0, threshold: false, contentPadding: [0.035, 0.045], - segments: foldingDoorSegments, + segments, } } @@ -438,6 +494,7 @@ export function DoorPanel() { doorType: nextDoorType, leafCount: 1, ...dimensionUpdates, + openingShape: 'rectangle', handle: true, handleSide: 'right', trackStyle: 'visible', @@ -445,7 +502,7 @@ export function DoorPanel() { operationState: node.operationState ?? 0, threshold: false, contentPadding: [0.035, 0.045], - segments: foldingDoorSegments, + segments, } } @@ -455,6 +512,7 @@ export function DoorPanel() { doorType: nextDoorType, leafCount: 2, ...dimensionUpdates, + openingShape: 'rectangle', handle: true, handleSide: 'right', trackStyle: 'visible', @@ -462,7 +520,7 @@ export function DoorPanel() { operationState: node.operationState ?? 0, threshold: false, contentPadding: [0.03, 0.04], - segments: frenchDoorSegments, + segments, } } @@ -479,7 +537,7 @@ export function DoorPanel() { operationState: 0, garagePanelCount: Math.max(3, Math.min(8, node.garagePanelCount ?? 4)), contentPadding: [0.04, 0.04], - segments: foldingDoorSegments, + segments, } } @@ -496,7 +554,7 @@ export function DoorPanel() { operationState: 0, garagePanelCount: 4, contentPadding: [0.04, 0.04], - segments: foldingDoorSegments, + segments, } } @@ -513,7 +571,7 @@ export function DoorPanel() { operationState: 0, garagePanelCount: 4, contentPadding: [0.04, 0.04], - segments: foldingDoorSegments, + segments, } } @@ -522,6 +580,7 @@ export function DoorPanel() { doorType: nextDoorType, leafCount: 1, ...dimensionUpdates, + segments, threshold: true, } } @@ -631,7 +690,7 @@ export function DoorPanel() { unit="m" value={Math.round(node.position[0] * 100) / 100} /> - {!isOpening && ( + {showFlipSide && (
- {doorType === 'folding' && !isOpening && ( + {showFoldSection && (
@@ -674,7 +733,7 @@ export function DoorPanel() { )} - {isSlidingDoor && !isOpening && ( + {showSlideSection && (
@@ -705,7 +764,7 @@ export function DoorPanel() { )} - {(isSectionalGarageDoor || isRollupGarageDoor || isTiltupGarageDoor) && !isOpening && ( + {showGarageSection && ( - {!isOpening && supportsTopShape && ( + {showDoorShapeSection && (
)} - {isOpening && ( + {showOpeningShapeSection && (
)} - {!isOpening && ( + {!isCutoutOnly && ( <> - + {showFrameSection && ( + - + + )} - {!isGarageDoor && ( + {showContentPaddingSection && ( )} - {isSwingDoor && ( + {showSwingSection && (
{supportsHingeSide && ( @@ -1044,7 +1105,7 @@ export function DoorPanel() { )} - {isSwingDoor && ( + {showThresholdSection && ( )} - {!isGarageDoor && ( + {showHandleSection && ( {isSwingDoor && ( )} - {isSwingDoor && ( + {showHardwareSection && ( )} - {!isGarageDoor && ( + {showSegmentsSection && ( {node.segments.map((seg, i) => { const numCols = seg.columnRatios.length diff --git a/packages/editor/src/components/ui/panels/elevator-panel.tsx b/packages/editor/src/components/ui/panels/elevator-panel.tsx index 02bbc4aa1..24017c5aa 100644 --- a/packages/editor/src/components/ui/panels/elevator-panel.tsx +++ b/packages/editor/src/components/ui/panels/elevator-panel.tsx @@ -166,6 +166,7 @@ export function ElevatorPanel() { if (!state) return null return { currentLevelId: state.currentLevelId, + requestedStops: state.requestedStops, queue: state.queue, targetLevelId: state.targetLevelId, } @@ -455,18 +456,13 @@ export function ElevatorPanel() { : fromLevelId || levels[0]?.id) ?? null const destinationOrderByLevelId = new Map() - const orderedDestinationIds: string[] = [] - if (runtime?.targetLevelId) orderedDestinationIds.push(runtime.targetLevelId) - for (const levelId of runtime?.queue ?? []) { - if (!orderedDestinationIds.includes(levelId)) orderedDestinationIds.push(levelId) - } - orderedDestinationIds.forEach((levelId, index) => { + for (const [index, levelId] of (runtime?.requestedStops ?? []).entries()) { destinationOrderByLevelId.set(levelId, index + 1) - }) + } return ( + +
+
+
+ From +
+ +
+ +
+
+ To +
+ +
+
+ +
+
+ Default Floor +
+ +
+
+ - -
-
-
- From -
- -
- -
-
- To -
- -
-
- -
-
- Default Floor -
- -
-
-
{servedLevels.map((level) => { diff --git a/packages/editor/src/components/ui/panels/fence-panel.tsx b/packages/editor/src/components/ui/panels/fence-panel.tsx index f269cba55..e53e3df37 100644 --- a/packages/editor/src/components/ui/panels/fence-panel.tsx +++ b/packages/editor/src/components/ui/panels/fence-panel.tsx @@ -7,7 +7,6 @@ import { getClampedWallCurveOffset, getMaxWallCurveOffset, getWallCurveLength, - type MaterialSchema, normalizeWallCurveOffset, useScene, } from '@pascal-app/core' @@ -19,7 +18,6 @@ import { useCallback } from 'react' import { sfxEmitter } from '../../../lib/sfx-bus' import useEditor from '../../../store/use-editor' import { ActionButton, ActionGroup } from '../controls/action-button' -import { MaterialPicker } from '../controls/material-picker' import { PanelSection } from '../controls/panel-section' import { SegmentedControl } from '../controls/segmented-control' import { SliderControl } from '../controls/slider-control' diff --git a/packages/editor/src/components/ui/panels/node-display.ts b/packages/editor/src/components/ui/panels/node-display.ts index 16ee20d9b..be0fd1c88 100644 --- a/packages/editor/src/components/ui/panels/node-display.ts +++ b/packages/editor/src/components/ui/panels/node-display.ts @@ -13,7 +13,7 @@ const TYPE_DEFAULTS: Record = { slab: { icon: '/icons/floor.png', label: 'Slab' }, ceiling: { icon: '/icons/ceiling.png', label: 'Ceiling' }, column: { icon: '/icons/column.png', label: 'Column' }, - elevator: { icon: '/icons/elevator.svg', label: 'Elevator' }, + elevator: { icon: '/icons/elevator.png', label: 'Elevator' }, fence: { icon: '/icons/fence.png', label: 'Fence' }, roof: { icon: '/icons/roof.png', label: 'Roof' }, 'roof-segment': { icon: '/icons/roof.png', label: 'Roof segment' }, diff --git a/packages/editor/src/components/ui/panels/roof-panel.tsx b/packages/editor/src/components/ui/panels/roof-panel.tsx index 917efdb69..384252ae2 100644 --- a/packages/editor/src/components/ui/panels/roof-panel.tsx +++ b/packages/editor/src/components/ui/panels/roof-panel.tsx @@ -3,25 +3,19 @@ import { type AnyNode, type AnyNodeId, - getEffectiveRoofSurfaceMaterial, - type MaterialSchema, type RoofNode, - RoofNode as RoofNodeSchema, type RoofSegmentNode, RoofSegmentNode as RoofSegmentNodeSchema, - type RoofSurfaceMaterialRole, useScene, } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { Copy, Move, Plus, Trash2 } from 'lucide-react' import { useCallback } from 'react' import { useShallow } from 'zustand/react/shallow' -import { buildRoofSurfaceMaterialPatch } from '../../../lib/material-paint' import { duplicateRoofSubtree } from '../../../lib/roof-duplication' import { sfxEmitter } from '../../../lib/sfx-bus' import useEditor from '../../../store/use-editor' import { ActionButton, ActionGroup } from '../controls/action-button' -import { MaterialPicker } from '../controls/material-picker' import { PanelSection } from '../controls/panel-section' import { SliderControl } from '../controls/slider-control' import { PanelWrapper } from './panel-wrapper' @@ -32,7 +26,6 @@ export function RoofPanel() { const updateNode = useScene((s) => s.updateNode) const createNode = useScene((s) => s.createNode) const setMovingNode = useEditor((s) => s.setMovingNode) - const selectedMaterialTarget = useEditor((s) => s.selectedMaterialTarget) const node = useScene((s) => selectedId ? (s.nodes[selectedId as AnyNode['id']] as RoofNode | undefined) : undefined, @@ -55,35 +48,6 @@ export function RoofPanel() { [selectedId, updateNode], ) - const materialTargetRole = - selectedMaterialTarget && - selectedMaterialTarget.nodeId === node?.id && - (selectedMaterialTarget.role === 'top' || - selectedMaterialTarget.role === 'edge' || - selectedMaterialTarget.role === 'wall') - ? selectedMaterialTarget.role - : null - const materialPickerValue = - node && materialTargetRole ? getEffectiveRoofSurfaceMaterial(node, materialTargetRole) : {} - - const handleTargetedMaterialChange = useCallback( - (material: MaterialSchema) => { - if (!(node && materialTargetRole)) return - handleUpdate(buildRoofSurfaceMaterialPatch(node, materialTargetRole, material, undefined)) - }, - [handleUpdate, materialTargetRole, node], - ) - - const handleTargetedMaterialPresetChange = useCallback( - (materialPreset: string) => { - if (!(node && materialTargetRole)) return - handleUpdate( - buildRoofSurfaceMaterialPatch(node, materialTargetRole, undefined, materialPreset), - ) - }, - [handleUpdate, materialTargetRole, node], - ) - const handleClose = useCallback(() => { setSelection({ selectedIds: [] }) }, [setSelection]) @@ -259,22 +223,6 @@ export function RoofPanel() { /> - - {materialTargetRole ? null : ( -
- Click the roof surface you want to edit. Materials apply to one target at a time. -
- )} - -
) } diff --git a/packages/editor/src/components/ui/panels/stair-panel.tsx b/packages/editor/src/components/ui/panels/stair-panel.tsx index a4cb8608a..90a998fca 100644 --- a/packages/editor/src/components/ui/panels/stair-panel.tsx +++ b/packages/editor/src/components/ui/panels/stair-panel.tsx @@ -3,16 +3,13 @@ import { type AnyNode, type AnyNodeId, - getEffectiveStairSurfaceMaterial, type LevelNode, - type MaterialSchema, type StairNode, StairNode as StairNodeSchema, type StairRailingMode, type StairSegmentNode, StairSegmentNode as StairSegmentNodeSchema, type StairSlabOpeningMode, - type StairSurfaceMaterialRole, type StairTopLandingMode, type StairType, useScene, @@ -21,13 +18,11 @@ import { useViewer } from '@pascal-app/viewer' import { Copy, Move, Plus, Trash2 } from 'lucide-react' import { useCallback } from 'react' import { useShallow } from 'zustand/react/shallow' -import { buildStairSurfaceMaterialPatch } from '../../../lib/material-paint' import { sfxEmitter } from '../../../lib/sfx-bus' import { duplicateStairSubtree } from '../../../lib/stair-duplication' import useEditor from '../../../store/use-editor' import { DEFAULT_SPIRAL_STAIR_SWEEP_ANGLE } from '../../tools/stair/stair-defaults' import { ActionButton, ActionGroup } from '../controls/action-button' -import { MaterialPicker } from '../controls/material-picker' import { MetricControl } from '../controls/metric-control' import { PanelSection } from '../controls/panel-section' import { SegmentedControl } from '../controls/segmented-control' @@ -65,7 +60,6 @@ export function StairPanel() { const updateNode = useScene((s) => s.updateNode) const createNode = useScene((s) => s.createNode) const setMovingNode = useEditor((s) => s.setMovingNode) - const selectedMaterialTarget = useEditor((s) => s.selectedMaterialTarget) const node = useScene((s) => selectedId ? (s.nodes[selectedId as AnyNode['id']] as StairNode | undefined) : undefined, @@ -96,35 +90,6 @@ export function StairPanel() { [selectedId, updateNode], ) - const materialTargetRole = - selectedMaterialTarget && - selectedMaterialTarget.nodeId === node?.id && - (selectedMaterialTarget.role === 'railing' || - selectedMaterialTarget.role === 'tread' || - selectedMaterialTarget.role === 'side') - ? selectedMaterialTarget.role - : null - const materialPickerValue = - node && materialTargetRole ? getEffectiveStairSurfaceMaterial(node, materialTargetRole) : {} - - const handleTargetedMaterialChange = useCallback( - (material: MaterialSchema) => { - if (!(node && materialTargetRole)) return - handleUpdate(buildStairSurfaceMaterialPatch(node, materialTargetRole, material, undefined)) - }, - [handleUpdate, materialTargetRole, node], - ) - - const handleTargetedMaterialPresetChange = useCallback( - (materialPreset: string) => { - if (!(node && materialTargetRole)) return - handleUpdate( - buildStairSurfaceMaterialPatch(node, materialTargetRole, undefined, materialPreset), - ) - }, - [handleUpdate, materialTargetRole, node], - ) - const handleClose = useCallback(() => { setSelection({ selectedIds: [] }) }, [setSelection]) @@ -556,22 +521,6 @@ export function StairPanel() { /> - - {materialTargetRole ? null : ( -
- Click the stair surface you want to edit. Materials apply to one target at a time. -
- )} - -
) } diff --git a/packages/editor/src/components/ui/panels/window-panel.tsx b/packages/editor/src/components/ui/panels/window-panel.tsx index b26cf0f0d..b042c7a88 100644 --- a/packages/editor/src/components/ui/panels/window-panel.tsx +++ b/packages/editor/src/components/ui/panels/window-panel.tsx @@ -319,17 +319,38 @@ export function WindowPanel() { const maxRoundedRadius = Math.max(0.01, getMaxSharedWindowRadius(node.width, node.height)) const displayedWindowType = node.windowType === 'hopper' ? 'awning' : (node.windowType ?? 'fixed') const awningDirection = node.windowType === 'hopper' ? 'down' : (node.awningDirection ?? 'up') - const isOperableWindow = - node.windowType === 'sliding' || - node.windowType === 'casement' || - node.windowType === 'awning' || - node.windowType === 'hopper' || - node.windowType === 'single-hung' || - node.windowType === 'double-hung' || - node.windowType === 'louvered' + const windowType = node.windowType ?? 'fixed' + const isFixedWindow = windowType === 'fixed' + const isTrackSashWindow = + windowType === 'sliding' || windowType === 'single-hung' || windowType === 'double-hung' + const isOperableSashWindow = + windowType === 'casement' || + windowType === 'awning' || + windowType === 'hopper' || + windowType === 'louvered' + const isOperableWindow = isTrackSashWindow || isOperableSashWindow const supportsWindowShape = shapedWindowTypes.has(node.windowType ?? 'fixed') - const supportsGrid = node.windowType === 'fixed' + const supportsGrid = isFixedWindow const supportsSill = !silllessWindowTypes.has(node.windowType) + const showWindowTypeSection = !isOpening + const showWindowShapeSection = !isOpening && supportsWindowShape + const showOpeningShapeSection = isOpening + const showFrameSection = !isOpening + const showGridSection = !isOpening && supportsGrid + const showSillSection = !isOpening && supportsSill + const showOperationSection = !isOpening && isOperableWindow + const showAwningDirectionSection = !isOpening && displayedWindowType === 'awning' + const showCasementSection = !isOpening && windowType === 'casement' + const showFlipSide = !isOpening + const operationLabel = isTrackSashWindow + ? windowType === 'sliding' + ? 'Slide' + : 'Raise' + : windowType === 'casement' + ? 'Swing' + : windowType === 'louvered' + ? 'Slats' + : 'Tilt' const setOperationState = (value: number) => { useInteractive.getState().cancelWindowAnimation(node.id) @@ -463,7 +484,7 @@ export function WindowPanel() { /> - {!isOpening && ( + {showWindowTypeSection && (
{windowTypeOptions.map((option) => { @@ -494,7 +515,7 @@ export function WindowPanel() { ) })}
- {displayedWindowType === 'awning' && ( + {showAwningDirectionSection && (
@@ -511,7 +532,7 @@ export function WindowPanel() { />
)} - {node.windowType === 'casement' && ( + {showCasementSection && (
@@ -537,10 +558,10 @@ export function WindowPanel() { )}
)} - {isOperableWindow && ( + {showOperationSection && (
- {!isOpening && ( + {showFlipSide && (
- {!isOpening && supportsWindowShape && ( - + {showWindowShapeSection && ( + handleUpdate({ @@ -717,7 +738,7 @@ export function WindowPanel() { )} - {isOpening && ( + {showOpeningShapeSection && ( @@ -810,7 +831,8 @@ export function WindowPanel() { {!isOpening && ( <> - + {showFrameSection && ( + - + + )} - {supportsGrid && ( + {showGridSection && ( )} - {supportsSill && ( + {showSillSection && ( + } isHovered={isHovered} isLast={isLast} diff --git a/packages/editor/src/lib/floorplan/stairs.ts b/packages/editor/src/lib/floorplan/stairs.ts index 68188a6d9..cbabc263d 100644 --- a/packages/editor/src/lib/floorplan/stairs.ts +++ b/packages/editor/src/lib/floorplan/stairs.ts @@ -209,6 +209,14 @@ function getNormalizedFloorplanStairSweepAngle(stair: StairNode) { return baseSweepAngle } +function clampFloorplanCircularSweepAngle(sweepAngle: number) { + if (Math.abs(sweepAngle) >= Math.PI * 2) { + return Math.sign(sweepAngle || 1) * (Math.PI * 2 - 0.001) + } + + return sweepAngle +} + function getFloorplanSpiralLandingSweep(stair: StairNode, sweepAngle: number) { if ( (stair.stairType ?? 'straight') !== 'spiral' || @@ -230,8 +238,11 @@ function getFloorplanSpiralLandingSweep(stair: StairNode, sweepAngle: number) { function getFloorplanCurvedStairHitPolygon(stair: StairNode): Point2D[] { const stairType = stair.stairType ?? 'straight' const sweepAngle = getNormalizedFloorplanStairSweepAngle(stair) + const visualSweepAngle = clampFloorplanCircularSweepAngle( + sweepAngle + getFloorplanSpiralLandingSweep(stair, sweepAngle), + ) const startAngle = -stair.rotation - sweepAngle / 2 - const endAngle = startAngle + sweepAngle + getFloorplanSpiralLandingSweep(stair, sweepAngle) + const endAngle = startAngle + visualSweepAngle const center = { x: stair.position[0], y: stair.position[2], diff --git a/packages/viewer/src/components/renderers/elevator/elevator-renderer.tsx b/packages/viewer/src/components/renderers/elevator/elevator-renderer.tsx index facfe5c82..6ee1c9762 100644 --- a/packages/viewer/src/components/renderers/elevator/elevator-renderer.tsx +++ b/packages/viewer/src/components/renderers/elevator/elevator-renderer.tsx @@ -35,10 +35,11 @@ import { import { useShallow } from 'zustand/react/shallow' import { useNodeEvents } from '../../../hooks/use-node-events' -const SHAFT_WALL_COLOR = '#d7dce4' -const SHAFT_SIDE_COLOR = '#4b5563' -const SHAFT_TRIM_COLOR = '#eef2f7' -const CAB_COLOR = '#d7dde5' +const DEFAULT_STRUCTURE_WHITE = '#f2f0ed' +const SHAFT_WALL_COLOR = DEFAULT_STRUCTURE_WHITE +const SHAFT_SIDE_COLOR = DEFAULT_STRUCTURE_WHITE +const SHAFT_TRIM_COLOR = DEFAULT_STRUCTURE_WHITE +const CAB_COLOR = DEFAULT_STRUCTURE_WHITE const GLASS_COLOR = '#f8fafc' const DOOR_COLOR = '#8e98a6' const PANEL_COLOR = '#1f2937' diff --git a/packages/viewer/src/components/renderers/item/item-renderer.tsx b/packages/viewer/src/components/renderers/item/item-renderer.tsx index 4a17f897e..5c7e74a09 100644 --- a/packages/viewer/src/components/renderers/item/item-renderer.tsx +++ b/packages/viewer/src/components/renderers/item/item-renderer.tsx @@ -1,12 +1,12 @@ import { - useInteractive, - useRegistry, - useScene, type AnimationEffect, type AnyNodeId, type Interactive, type ItemNode, type LightEffect, + useInteractive, + useRegistry, + useScene, } from '@pascal-app/core' import { useAnimations } from '@react-three/drei' import { Clone } from '@react-three/drei/core/Clone' diff --git a/packages/viewer/src/components/renderers/node-renderer.tsx b/packages/viewer/src/components/renderers/node-renderer.tsx index f2045fb49..48a8bc183 100644 --- a/packages/viewer/src/components/renderers/node-renderer.tsx +++ b/packages/viewer/src/components/renderers/node-renderer.tsx @@ -1,6 +1,6 @@ 'use client' -import { useScene, type AnyNode } from '@pascal-app/core' +import { type AnyNode, useScene } from '@pascal-app/core' import { BuildingRenderer } from './building/building-renderer' import { CeilingRenderer } from './ceiling/ceiling-renderer' import { ColumnRenderer } from './column/column-renderer' @@ -10,14 +10,14 @@ import { FenceRenderer } from './fence/fence-renderer' import { GuideRenderer } from './guide/guide-renderer' import { ItemRenderer } from './item/item-renderer' import { LevelRenderer } from './level/level-renderer' -import { RoofSegmentRenderer } from './roof-segment/roof-segment-renderer' import { RoofRenderer } from './roof/roof-renderer' +import { RoofSegmentRenderer } from './roof-segment/roof-segment-renderer' import { ScanRenderer } from './scan/scan-renderer' import { SiteRenderer } from './site/site-renderer' import { SlabRenderer } from './slab/slab-renderer' import { SpawnRenderer } from './spawn/spawn-renderer' -import { StairSegmentRenderer } from './stair-segment/stair-segment-renderer' import { StairRenderer } from './stair/stair-renderer' +import { StairSegmentRenderer } from './stair-segment/stair-segment-renderer' import { WallRenderer } from './wall/wall-renderer' import { WindowRenderer } from './window/window-renderer' import { ZoneRenderer } from './zone/zone-renderer' diff --git a/packages/viewer/src/components/renderers/roof/roof-renderer.tsx b/packages/viewer/src/components/renderers/roof/roof-renderer.tsx index 1dee2a983..b9affd116 100644 --- a/packages/viewer/src/components/renderers/roof/roof-renderer.tsx +++ b/packages/viewer/src/components/renderers/roof/roof-renderer.tsx @@ -1,5 +1,5 @@ -import { type RoofNode, useRegistry } from '@pascal-app/core' -import { useEffect, useMemo, useRef } from 'react' +import { type RoofNode, useRegistry, useScene } from '@pascal-app/core' +import { useEffect, useLayoutEffect, useMemo, useRef } from 'react' import * as THREE from 'three' import { useNodeEvents } from '../../../hooks/use-node-events' import useViewer from '../../../store/use-viewer' @@ -11,6 +11,9 @@ export const RoofRenderer = ({ node }: { node: RoofNode }) => { const ref = useRef(null!) useRegistry(node.id, 'roof', ref) + useLayoutEffect(() => { + useScene.getState().markDirty(node.id) + }, [node.id]) const handlers = useNodeEvents(node, 'roof') const debugColors = useViewer((s) => s.debugColors) diff --git a/packages/viewer/src/components/renderers/site/site-renderer.tsx b/packages/viewer/src/components/renderers/site/site-renderer.tsx index fc66eea62..9c8d06499 100644 --- a/packages/viewer/src/components/renderers/site/site-renderer.tsx +++ b/packages/viewer/src/components/renderers/site/site-renderer.tsx @@ -1,6 +1,6 @@ -import { useRegistry, useScene, type SiteNode, type SlabNode } from '@pascal-app/core' +import { type SiteNode, type SlabNode, useRegistry, useScene } from '@pascal-app/core' import { useMemo, useRef } from 'react' -import { BufferGeometry, Float32BufferAttribute, Path, Shape, type Group } from 'three' +import { BufferGeometry, Float32BufferAttribute, type Group, Path, Shape } from 'three' import { useNodeEvents } from '../../../hooks/use-node-events' import { unionPolygons } from '../../../lib/polygon-union' import useViewer from '../../../store/use-viewer' diff --git a/packages/viewer/src/components/renderers/slab/slab-renderer.tsx b/packages/viewer/src/components/renderers/slab/slab-renderer.tsx index 330c5d1b5..92552eadb 100644 --- a/packages/viewer/src/components/renderers/slab/slab-renderer.tsx +++ b/packages/viewer/src/components/renderers/slab/slab-renderer.tsx @@ -19,22 +19,30 @@ function createEmptyGeometry() { function getSlabMaterial( cacheKey: string, - params: { material?: SlabNode['material']; materialPreset?: string }, + params: { + material?: SlabNode['material'] + materialPreset?: string + preset?: ReturnType + }, ) { const cached = slabMaterialCache.get(cacheKey) - if (cached) return cached + if (cached) { + if (params.preset) { + applyMaterialPresetToMaterials(cached, params.preset) + } + return cached + } - const preset = getMaterialPresetByRef(params.materialPreset) - const slabMaterial = preset + const slabMaterial = params.preset ? new THREE.MeshStandardMaterial() : params.material ? createMaterial(params.material).clone() : DEFAULT_SLAB_MATERIAL.clone() - if (preset) { + if (params.preset) { // Apply the preset to the slab-owned material so async texture loads update // the instance we actually render after refresh as well. - applyMaterialPresetToMaterials(slabMaterial, preset) + applyMaterialPresetToMaterials(slabMaterial, params.preset) } // Slabs participate in the WebGPU MRT scene pass. Keeping them opaque avoids @@ -64,14 +72,17 @@ export const SlabRenderer = ({ node }: { node: SlabNode }) => { const material = useMemo(() => { const resolvedMaterial = node.material const resolvedMaterialPreset = node.materialPreset + const preset = getMaterialPresetByRef(resolvedMaterialPreset) const cacheKey = JSON.stringify({ material: resolvedMaterial ?? null, materialPreset: resolvedMaterialPreset ?? null, + preset: preset ?? null, }) return getSlabMaterial(cacheKey, { material: resolvedMaterial, materialPreset: resolvedMaterialPreset, + preset, }) }, [ node.material, diff --git a/packages/viewer/src/components/viewer/index.tsx b/packages/viewer/src/components/viewer/index.tsx index e1aee9b1b..684842abe 100644 --- a/packages/viewer/src/components/viewer/index.tsx +++ b/packages/viewer/src/components/viewer/index.tsx @@ -1,6 +1,6 @@ 'use client' -import { ElevatorOpeningSystem, ElevatorRuntimeSystem } from '@pascal-app/core' +import { ElevatorOpeningSystem, ElevatorRuntimeSystem, StairOpeningSystem } from '@pascal-app/core' import { Canvas, extend, type ThreeToJSXElements, useFrame, useThree } from '@react-three/fiber' import { useEffect, useMemo, useRef } from 'react' import * as THREE from 'three/webgpu' @@ -12,8 +12,8 @@ import { DoorSystem } from '../../systems/door/door-system' import { ElevatorInteractionSystem } from '../../systems/elevator/elevator-interaction-system' import { FenceSystem } from '../../systems/fence/fence-system' import { GuideSystem } from '../../systems/guide/guide-system' -import { ItemLightSystem } from '../../systems/item-light/item-light-system' import { ItemSystem } from '../../systems/item/item-system' +import { ItemLightSystem } from '../../systems/item-light/item-light-system' import { LevelSystem } from '../../systems/level/level-system' import { RoofSystem } from '../../systems/roof/roof-system' import { ScanSystem } from '../../systems/scan/scan-system' @@ -233,6 +233,7 @@ const Viewer: React.FC = ({ + diff --git a/packages/viewer/src/components/viewer/post-processing.tsx b/packages/viewer/src/components/viewer/post-processing.tsx index b64ffabfa..8e0c873ed 100644 --- a/packages/viewer/src/components/viewer/post-processing.tsx +++ b/packages/viewer/src/components/viewer/post-processing.tsx @@ -127,11 +127,12 @@ const PostProcessingPasses = ({ }: { hoverStyles?: HoverStyles }) => { - const { gl: renderer, invalidate, scene, camera } = useThree() + const { gl: renderer, invalidate, scene, camera, size } = useThree() const renderPipelineRef = useRef(null) const hasPipelineErrorRef = useRef(false) const retryCountRef = useRef(0) const rebuildTimeoutRef = useRef | null>(null) + const skippedZeroSizeRef = useRef(false) // Background color uniform — updated every frame via lerp, read by the TSL pipeline. // Initialised from the current theme so there's no flash on first render. @@ -207,6 +208,9 @@ const PostProcessingPasses = ({ // Build / rebuild the post-processing pipeline useEffect(() => { + const width = Math.floor(size.width) + const height = Math.floor(size.height) + if (!(renderer && scene && camera)) { console.warn('[viewer/post-processing] Skipping pipeline build — missing dependency.', { hasRenderer: !!renderer, @@ -216,6 +220,24 @@ const PostProcessingPasses = ({ return } + if (width < 1 || height < 1) { + skippedZeroSizeRef.current = true + hasPipelineErrorRef.current = false + if (renderPipelineRef.current) { + renderPipelineRef.current.dispose() + } + renderPipelineRef.current = null + return + } + + if (skippedZeroSizeRef.current) { + console.log('[viewer/post-processing] Rebuilding pipeline after zero-sized viewport.', { + width, + height, + }) + skippedZeroSizeRef.current = false + } + const perfDisable = readPerfDisableFlags() const ssgiEnabled = SSGI_PARAMS.enabled && !perfDisable.ao const denoiseEnabled = ssgiEnabled && !perfDisable.denoise @@ -230,6 +252,8 @@ const PostProcessingPasses = ({ hoverHighlightMode, projectId, rendererCtor: (renderer as any).constructor?.name, + width, + height, }) hasPipelineErrorRef.current = false @@ -413,10 +437,16 @@ const PostProcessingPasses = ({ projectId, renderer, scene, + size.height, + size.width, zoneLayers, ]) useFrame((_, delta) => { + if (size.width < 1 || size.height < 1) { + return + } + // Animate background colour toward the current theme target (same lerp as AnimatedBackground) bgTarget.current.set(useViewer.getState().theme === 'dark' ? DARK_BG : LIGHT_BG) bgCurrent.current.lerp(bgTarget.current, Math.min(delta, 0.1) * 4) diff --git a/packages/viewer/src/components/viewer/scene-bvh.tsx b/packages/viewer/src/components/viewer/scene-bvh.tsx index c4517e6b5..7d7dcc6b2 100644 --- a/packages/viewer/src/components/viewer/scene-bvh.tsx +++ b/packages/viewer/src/components/viewer/scene-bvh.tsx @@ -1,17 +1,11 @@ import { useThree } from '@react-three/fiber' +import { forwardRef, type ReactNode, useEffect, useImperativeHandle, useRef } from 'react' +import { type BufferGeometry, type Group, Mesh } from 'three' import { - type ReactNode, - forwardRef, - useEffect, - useImperativeHandle, - useRef, -} from 'react' -import { Group, Mesh, type BufferGeometry } from 'three' -import { - SAH, acceleratedRaycast, computeBoundsTree, disposeBoundsTree, + SAH, type SplitStrategy, } from 'three-mesh-bvh' diff --git a/packages/viewer/src/hooks/use-gltf-ktx2.tsx b/packages/viewer/src/hooks/use-gltf-ktx2.tsx index 28578c76b..040beaf7e 100644 --- a/packages/viewer/src/hooks/use-gltf-ktx2.tsx +++ b/packages/viewer/src/hooks/use-gltf-ktx2.tsx @@ -36,4 +36,5 @@ const useGLTFKTX2 = (path: string): ReturnType => { loader.setMeshoptDecoder(MeshoptDecoder) }) } + export { useGLTFKTX2 } diff --git a/packages/viewer/src/lib/materials.ts b/packages/viewer/src/lib/materials.ts index b1cb369e2..27cb7cdba 100644 --- a/packages/viewer/src/lib/materials.ts +++ b/packages/viewer/src/lib/materials.ts @@ -1,10 +1,10 @@ import { getMaterialPresetByRef, - resolveMaterial, type MaterialMapProperties, type MaterialPresetPayload, type MaterialProperties, type MaterialSchema, + resolveMaterial, } from '@pascal-app/core' import * as THREE from 'three' import { MeshLambertNodeMaterial, MeshStandardNodeMaterial } from 'three/webgpu' @@ -52,6 +52,26 @@ type TextureSlot = | 'emissiveMap' const SRGB_TEXTURE_SLOTS: TextureSlot[] = ['map', 'emissiveMap'] +const TEXTURE_SLOTS: TextureSlot[] = [ + 'map', + 'normalMap', + 'roughnessMap', + 'metalnessMap', + 'displacementMap', + 'aoMap', + 'bumpMap', + 'alphaMap', + 'lightMap', + 'emissiveMap', +] + +function getTextureChannel(slot?: TextureSlot): number { + if (slot === 'aoMap' || slot === 'lightMap') { + return 2 + } + + return 0 +} function getCacheKey(props: MaterialProperties): string { return `${props.color}-${props.roughness}-${props.metalness}-${props.opacity}-${props.transparent}-${props.side}` @@ -80,6 +100,7 @@ function getTexture(material?: MaterialSchema): THREE.Texture | undefined { const repeatX = textureConfig.repeat?.[0] ?? textureConfig.scale ?? 1 const repeatY = textureConfig.repeat?.[1] ?? textureConfig.scale ?? 1 texture.repeat.set(repeatX, repeatY) + texture.updateMatrix() texture.colorSpace = THREE.SRGBColorSpace textureCache.set(cacheKey, texture) @@ -102,6 +123,8 @@ function applyTextureProperties( texture.repeat.set(props.repeatX, props.repeatY) texture.rotation = props.rotation texture.flipY = props.flipY + texture.updateMatrix() + texture.channel = getTextureChannel(slot) texture.colorSpace = SRGB_TEXTURE_SLOTS.includes(slot ?? 'map') ? THREE.SRGBColorSpace : THREE.NoColorSpace @@ -132,6 +155,26 @@ function getPresetTexture( return texture } +function createAssignedTexture( + source: THREE.Texture, + props: MaterialMapProperties, + slot?: TextureSlot, +): THREE.Texture { + const texture = source.clone() + return applyTextureProperties(texture, props, slot) +} + +function applyTexturePropertiesToMaterial( + material: StandardMaterial, + props: MaterialMapProperties, +) { + for (const slot of TEXTURE_SLOTS) { + const texture = material[slot] + if (!texture) continue + applyTextureProperties(texture, props, slot) + } +} + async function loadPresetTexture( path: string, props: MaterialMapProperties, @@ -176,7 +219,8 @@ function queueTextureAssignment( const cacheKey = getPresetTextureCacheKey(path, props, slot) const cached = textureCache.get(cacheKey) if (cached) { - material[slot] = cached + material[slot] = createAssignedTexture(cached, props, slot) + material.needsUpdate = true return } @@ -184,7 +228,7 @@ function queueTextureAssignment( loadPresetTexture(path, props, slot).then((texture) => { if (!texture) return - material[slot] = texture + material[slot] = createAssignedTexture(texture, props, slot) material.needsUpdate = true }) } @@ -211,6 +255,7 @@ function applyMaterialMapProperties( ? THREE.BackSide : THREE.DoubleSide material.normalScale.set(mapProperties.normalScaleX, mapProperties.normalScaleY) + applyTexturePropertiesToMaterial(material, mapProperties) material.needsUpdate = true } diff --git a/packages/viewer/src/systems/door/door-system.tsx b/packages/viewer/src/systems/door/door-system.tsx index f309a55a7..c8e4da269 100644 --- a/packages/viewer/src/systems/door/door-system.tsx +++ b/packages/viewer/src/systems/door/door-system.tsx @@ -35,7 +35,10 @@ export const DoorSystem = () => { clearDirty(id as AnyNodeId) // Rebuild the parent wall so its cutout reflects the updated door geometry - if ((node as DoorNode).parentId) { + // Avoid triggering expensive wall CSG rebuilds while the door is being interactively moved/duplicated. + // The editor tools will request a final wall rebuild on commit. + const isTransient = !!(node.metadata as Record | null)?.isTransient + if (!isTransient && (node as DoorNode).parentId) { useScene.getState().dirtyNodes.add((node as DoorNode).parentId as AnyNodeId) } }) @@ -109,6 +112,25 @@ function addShape( parent.add(mesh) } +function addShapeAt( + parent: THREE.Object3D, + material: THREE.Material, + shape: THREE.Shape, + depth: number, + x: number, + y: number, + z: number, +) { + const geometry = new THREE.ExtrudeGeometry(shape, { + depth, + bevelEnabled: false, + curveSegments: 24, + }) + geometry.translate(x, y, z - depth / 2) + const mesh = new THREE.Mesh(geometry, material) + parent.add(mesh) +} + function getClampedArchHeight(width: number, height: number, archHeight: number | undefined) { return Math.min(Math.max(archHeight ?? width / 2, 0.01), Math.max(height, 0.01)) } @@ -145,6 +167,51 @@ function getArchBoundaryY(x: number, halfWidth: number, springY: number, archHei return springY + archHeight * Math.sqrt(Math.max(1 - t * t, 0)) } +function getOneSidedArchBoundaryYAtX( + x: number, + left: number, + right: number, + top: number, + archHeight: number, + curvedSide: 'left' | 'right', +) { + const width = right - left + if (width <= 1e-6) return top + const clampedArchHeight = getClampedArchHeight(width, Number.MAX_SAFE_INTEGER, archHeight) + const springY = top - clampedArchHeight + const distanceFromApex = + curvedSide === 'left' + ? Math.max(0, Math.min((right - x) / width, 1)) + : Math.max(0, Math.min((x - left) / width, 1)) + return ( + springY + clampedArchHeight * Math.sqrt(Math.max(1 - distanceFromApex * distanceFromApex, 0)) + ) +} + +function getRoundedBoundaryYAtX( + x: number, + left: number, + right: number, + top: number, + radii: TopCornerRadii, +) { + if (radii.topLeft > 1e-6 && x < left + radii.topLeft) { + const centerX = left + radii.topLeft + const centerY = top - radii.topLeft + const dx = x - centerX + return centerY + Math.sqrt(Math.max(radii.topLeft * radii.topLeft - dx * dx, 0)) + } + + if (radii.topRight > 1e-6 && x > right - radii.topRight) { + const centerX = right - radii.topRight + const centerY = top - radii.topRight + const dx = x - centerX + return centerY + Math.sqrt(Math.max(radii.topRight * radii.topRight - dx * dx, 0)) + } + + return top +} + function createArchBandShape( width: number, outerSpringY: number, @@ -358,6 +425,170 @@ function createRoundedLeafFrameShape( return outer } +function createRoundedClippedLeafFrameShape( + left: number, + right: number, + bottom: number, + top: number, + fullLeft: number, + fullRight: number, + radii: TopCornerRadii, + insetX: number, + insetY: number, +) { + const outerRadii = normalizeTopCornerRadii(radii, fullRight - fullLeft, top - bottom) + const outer = createTopClippedRectShape(left, right, bottom, top, (x) => + getRoundedBoundaryYAtX(x, fullLeft, fullRight, top, { + topLeft: outerRadii.topLeft, + topRight: outerRadii.topRight, + }), + ) + + if (!outer) return null + + const innerLeft = left + insetX + const innerRight = right - insetX + const innerBottom = bottom + insetY + const innerTop = top - insetY + + if (innerRight <= innerLeft + 0.01 || innerTop <= innerBottom + 0.01) return outer + + const innerFullLeft = fullLeft + insetX + const innerFullRight = fullRight - insetX + const innerRadii = normalizeTopCornerRadii( + { + topLeft: Math.max(outerRadii.topLeft - Math.max(insetX, insetY), 0), + topRight: Math.max(outerRadii.topRight - Math.max(insetX, insetY), 0), + }, + innerFullRight - innerFullLeft, + innerTop - innerBottom, + ) + const holeShape = createTopClippedRectShape(innerLeft, innerRight, innerBottom, innerTop, (x) => + getRoundedBoundaryYAtX(x, innerFullLeft, innerFullRight, innerTop, { + topLeft: innerRadii.topLeft, + topRight: innerRadii.topRight, + }), + ) + + if (holeShape) outer.holes.push(shapeToReversedPath(holeShape)) + + return outer +} + +function createArchedLeafFrameShape( + width: number, + bottom: number, + top: number, + archHeight: number, + insetX: number, + insetY: number, +) { + const halfWidth = width / 2 + const outer = createArchShape(-halfWidth, halfWidth, bottom, top, archHeight) + const innerLeft = -halfWidth + insetX + const innerRight = halfWidth - insetX + const innerBottom = bottom + insetY + const innerTop = top - insetY + + if (innerRight <= innerLeft + 0.01 || innerTop <= innerBottom + 0.01) return outer + + const innerArchHeight = getClampedArchHeight( + innerRight - innerLeft, + innerTop - innerBottom, + Math.max(archHeight - insetY, 0.01), + ) + outer.holes.push( + shapeToReversedPath( + createArchShape(innerLeft, innerRight, innerBottom, innerTop, innerArchHeight), + ), + ) + + return outer +} + +function createArchedClippedLeafFrameShape( + left: number, + right: number, + bottom: number, + top: number, + fullLeft: number, + fullRight: number, + archHeight: number, + insetX: number, + insetY: number, +) { + const fullCenterX = (fullLeft + fullRight) / 2 + const fullHalfWidth = (fullRight - fullLeft) / 2 + const springY = top - archHeight + const outer = createTopClippedRectShape(left, right, bottom, top, (x) => + getArchBoundaryY(x - fullCenterX, fullHalfWidth, springY, archHeight), + ) + + if (!outer) return null + + const innerLeft = left + insetX + const innerRight = right - insetX + const innerBottom = bottom + insetY + const innerTop = top - insetY + + if (innerRight <= innerLeft + 0.01 || innerTop <= innerBottom + 0.01) return outer + + const innerFullLeft = fullLeft + insetX + const innerFullRight = fullRight - insetX + const innerArchHeight = getClampedArchHeight( + innerFullRight - innerFullLeft, + innerTop - innerBottom, + Math.max(archHeight - insetY, 0.01), + ) + const innerFullCenterX = (innerFullLeft + innerFullRight) / 2 + const innerFullHalfWidth = (innerFullRight - innerFullLeft) / 2 + const innerSpringY = innerTop - innerArchHeight + const holeShape = createTopClippedRectShape(innerLeft, innerRight, innerBottom, innerTop, (x) => + getArchBoundaryY(x - innerFullCenterX, innerFullHalfWidth, innerSpringY, innerArchHeight), + ) + + if (holeShape) outer.holes.push(shapeToReversedPath(holeShape)) + + return outer +} + +function createOneSidedArchLeafFrameShape( + left: number, + right: number, + bottom: number, + top: number, + archHeight: number, + insetX: number, + insetY: number, + curvedSide: 'left' | 'right', +) { + const outer = createTopClippedRectShape(left, right, bottom, top, (x) => + getOneSidedArchBoundaryYAtX(x, left, right, top, archHeight, curvedSide), + ) + + if (!outer) return null + + const innerLeft = left + insetX + const innerRight = right - insetX + const innerBottom = bottom + insetY + const innerTop = top - insetY + + if (innerRight <= innerLeft + 0.01 || innerTop <= innerBottom + 0.01) return outer + + const innerArchHeight = getClampedArchHeight( + innerRight - innerLeft, + innerTop - innerBottom, + Math.max(archHeight - insetY, 0.01), + ) + const holeShape = createTopClippedRectShape(innerLeft, innerRight, innerBottom, innerTop, (x) => + getOneSidedArchBoundaryYAtX(x, innerLeft, innerRight, innerTop, innerArchHeight, curvedSide), + ) + + if (holeShape) outer.holes.push(shapeToReversedPath(holeShape)) + + return outer +} + function createTopClippedRectShape( left: number, right: number, @@ -395,6 +626,7 @@ function disposeObject(object: THREE.Object3D) { function addLeafSegmentContent({ addLeafBox, + addLeafShape, leafWidth, leafHeight, leafCenterX, @@ -403,6 +635,11 @@ function addLeafSegmentContent({ segments, contentPadding, keepFrameWhenEmpty = false, + renderPerimeterFrame = true, + openingShape = 'rectangle', + openingTopRadii, + archHeight, + archOuterSide, }: { addLeafBox: ( material: THREE.Material, @@ -413,6 +650,7 @@ function addLeafSegmentContent({ y: number, z: number, ) => void + addLeafShape?: (material: THREE.Material, shape: THREE.Shape, depth: number) => void leafWidth: number leafHeight: number leafCenterX: number @@ -421,12 +659,17 @@ function addLeafSegmentContent({ segments: DoorNode['segments'] contentPadding: DoorNode['contentPadding'] keepFrameWhenEmpty?: boolean + renderPerimeterFrame?: boolean + openingShape?: DoorNode['openingShape'] + openingTopRadii?: TopCornerRadii + archHeight?: number + archOuterSide?: 'left' | 'right' }) { const hasLeafContent = segments.some((seg) => seg.type !== 'empty') const shouldRenderFrame = hasLeafContent || keepFrameWhenEmpty const cpX = contentPadding[0] const cpY = contentPadding[1] - if (shouldRenderFrame && cpY > 0) { + if (renderPerimeterFrame && shouldRenderFrame && cpY > 0) { addLeafBox( baseMaterial, leafWidth, @@ -446,7 +689,7 @@ function addLeafSegmentContent({ 0, ) } - if (shouldRenderFrame && cpX > 0) { + if (renderPerimeterFrame && shouldRenderFrame && cpX > 0) { const innerH = leafHeight - 2 * cpY addLeafBox( baseMaterial, @@ -474,9 +717,44 @@ function addLeafSegmentContent({ const contentTop = leafCenterY + contentH / 2 let segY = contentTop + const leafLeft = leafCenterX - leafWidth / 2 + const leafRight = leafCenterX + leafWidth / 2 + const leafTop = leafCenterY + leafHeight / 2 + const hasShapedTop = openingShape === 'rounded' || openingShape === 'arch' + const clampedLeafArchHeight = + openingShape === 'arch' ? getClampedArchHeight(leafWidth, leafHeight, archHeight) : 0 + const leafSpringY = leafTop - clampedLeafArchHeight + const topBoundaryAtX = (x: number) => { + if (openingShape === 'rounded' && openingTopRadii) { + return getRoundedBoundaryYAtX(x, leafLeft, leafRight, leafTop, { + topLeft: openingTopRadii.topLeft, + topRight: openingTopRadii.topRight, + }) + } + + if (openingShape === 'arch') { + if (archOuterSide) { + return getOneSidedArchBoundaryYAtX( + x, + leafLeft, + leafRight, + leafTop, + clampedLeafArchHeight, + archOuterSide, + ) + } + + return getArchBoundaryY(x - leafCenterX, leafWidth / 2, leafSpringY, clampedLeafArchHeight) + } + + return leafTop + } + for (const seg of segments) { const segH = (seg.heightRatio / totalRatio) * contentH const segCenterY = segY - segH / 2 + const segTop = segY + const segBottom = segY - segH const numCols = seg.columnRatios.length const colSum = seg.columnRatios.reduce((a, b) => a + b, 0) const usableW = contentW - (numCols - 1) * seg.dividerThickness @@ -494,15 +772,32 @@ function addLeafSegmentContent({ cx = leafCenterX - contentW / 2 for (let c = 0; c < numCols - 1; c++) { cx += colWidths[c]! - addLeafBox( - baseMaterial, - seg.dividerThickness, - segH, - leafDepth + 0.001, - cx + seg.dividerThickness / 2, - segCenterY, - 0, - ) + const dividerLeft = cx + const dividerRight = cx + seg.dividerThickness + const dividerShape = + hasShapedTop && addLeafShape + ? createTopClippedRectShape( + dividerLeft, + dividerRight, + segBottom, + segTop, + topBoundaryAtX, + ) + : null + + if (dividerShape && addLeafShape) { + addLeafShape(baseMaterial, dividerShape, leafDepth + 0.001) + } else { + addLeafBox( + baseMaterial, + seg.dividerThickness, + segH, + leafDepth + 0.001, + cx + seg.dividerThickness / 2, + segCenterY, + 0, + ) + } cx += seg.dividerThickness } } @@ -513,15 +808,68 @@ function addLeafSegmentContent({ if (seg.type === 'glass') { const glassDepth = Math.max(0.004, leafDepth * 0.15) - addLeafBox(glassMaterial, colW, segH, glassDepth, colX, segCenterY, 0) + const segmentLeft = colX - colW / 2 + const segmentRight = colX + colW / 2 + const glassShape = + hasShapedTop && addLeafShape + ? createTopClippedRectShape( + segmentLeft, + segmentRight, + segBottom, + segTop, + topBoundaryAtX, + ) + : null + + if (glassShape && addLeafShape) { + addLeafShape(glassMaterial, glassShape, glassDepth) + } else { + addLeafBox(glassMaterial, colW, segH, glassDepth, colX, segCenterY, 0) + } } else if (seg.type === 'panel') { - addLeafBox(baseMaterial, colW, segH, leafDepth, colX, segCenterY, 0) + const segmentLeft = colX - colW / 2 + const segmentRight = colX + colW / 2 + const outerPanelShape = + hasShapedTop && addLeafShape + ? createTopClippedRectShape( + segmentLeft, + segmentRight, + segBottom, + segTop, + topBoundaryAtX, + ) + : null + + if (outerPanelShape && addLeafShape) { + addLeafShape(baseMaterial, outerPanelShape, leafDepth) + } else { + addLeafBox(baseMaterial, colW, segH, leafDepth, colX, segCenterY, 0) + } const panelW = colW - 2 * seg.panelInset const panelH = segH - 2 * seg.panelInset if (panelW > 0.01 && panelH > 0.01) { const effectiveDepth = Math.abs(seg.panelDepth) < 0.002 ? 0.005 : Math.abs(seg.panelDepth) const panelZ = leafDepth / 2 + effectiveDepth / 2 - addLeafBox(baseMaterial, panelW, panelH, effectiveDepth, colX, segCenterY, panelZ) + const insetLeft = colX - panelW / 2 + const insetRight = colX + panelW / 2 + const insetTop = segTop - seg.panelInset + const insetBottom = segBottom + seg.panelInset + const innerPanelShape = + hasShapedTop && addLeafShape + ? createTopClippedRectShape( + insetLeft, + insetRight, + insetBottom, + insetTop, + topBoundaryAtX, + ) + : null + + if (innerPanelShape && addLeafShape) { + addLeafShape(baseMaterial, innerPanelShape, effectiveDepth) + } else { + addLeafBox(baseMaterial, panelW, panelH, effectiveDepth, colX, segCenterY, panelZ) + } } } } @@ -551,6 +899,12 @@ function addDoorLeaf( panicBar, panicBarHeight, doorHeight, + openingShape, + openingTopRadii, + archHeight, + roundedBoundary, + archedBoundary, + archOuterSide, }: { leafWidth: number leafHeight: number @@ -570,6 +924,20 @@ function addDoorLeaf( panicBar: boolean panicBarHeight: number doorHeight: number + openingShape: DoorNode['openingShape'] + openingTopRadii: TopCornerRadii + archHeight: number + roundedBoundary?: { + fullLeft: number + fullRight: number + radii: TopCornerRadii + } + archedBoundary?: { + fullLeft: number + fullRight: number + archHeight: number + } + archOuterSide?: 'left' | 'right' }, ) { const hasLeafContent = segments.some((seg) => seg.type !== 'empty') @@ -587,9 +955,90 @@ function addDoorLeaf( y: number, z: number, ) => addBox(leafGroup, material, w, h, d, x - hingeX, y, z) + const addLeafShape = (material: THREE.Material, shape: THREE.Shape, depth: number) => + addShapeAt(leafGroup, material, shape, depth, -hingeX, 0, 0) + + const localLeafCenterX = leafCenterX - hingeX + const leafBottom = leafCenterY - leafHeight / 2 + const leafTop = leafCenterY + leafHeight / 2 + const usesShapedLeafFrame = openingShape === 'rounded' || openingShape === 'arch' + + if (usesShapedLeafFrame && hasLeafContent) { + if (openingShape === 'rounded') { + const roundedLeafShape = roundedBoundary + ? createRoundedClippedLeafFrameShape( + leafCenterX - leafWidth / 2, + leafCenterX + leafWidth / 2, + leafBottom, + leafTop, + roundedBoundary.fullLeft, + roundedBoundary.fullRight, + roundedBoundary.radii, + contentPadding[0], + contentPadding[1], + ) + : createRoundedLeafFrameShape( + leafWidth, + leafBottom, + leafTop, + openingTopRadii, + contentPadding[0], + contentPadding[1], + ) + + if (roundedLeafShape) { + if (roundedBoundary) { + addLeafShape(baseMaterial, roundedLeafShape, leafDepth) + } else { + addShapeAt(leafGroup, baseMaterial, roundedLeafShape, leafDepth, localLeafCenterX, 0, 0) + } + } + } else if (openingShape === 'arch') { + const archedLeafShape = archOuterSide + ? createOneSidedArchLeafFrameShape( + leafCenterX - leafWidth / 2, + leafCenterX + leafWidth / 2, + leafBottom, + leafTop, + archHeight, + contentPadding[0], + contentPadding[1], + archOuterSide, + ) + : archedBoundary + ? createArchedClippedLeafFrameShape( + leafCenterX - leafWidth / 2, + leafCenterX + leafWidth / 2, + leafBottom, + leafTop, + archedBoundary.fullLeft, + archedBoundary.fullRight, + archedBoundary.archHeight, + contentPadding[0], + contentPadding[1], + ) + : createArchedLeafFrameShape( + leafWidth, + leafBottom, + leafTop, + getClampedArchHeight(leafWidth, leafHeight, archHeight), + contentPadding[0], + contentPadding[1], + ) + + if (archedLeafShape) { + if (archOuterSide || archedBoundary) { + addLeafShape(baseMaterial, archedLeafShape, leafDepth) + } else { + addShapeAt(leafGroup, baseMaterial, archedLeafShape, leafDepth, localLeafCenterX, 0, 0) + } + } + } + } addLeafSegmentContent({ addLeafBox, + addLeafShape, leafWidth, leafHeight, leafCenterX, @@ -597,6 +1046,11 @@ function addDoorLeaf( leafDepth, segments, contentPadding, + renderPerimeterFrame: !usesShapedLeafFrame, + openingShape, + openingTopRadii, + archHeight, + archOuterSide, }) if (hasLeafContent && handle) { @@ -640,8 +1094,6 @@ function addDoorLeaf( const hingeH = 0.1 const hingeW = 0.024 const hingeD = leafDepth + 0.016 - const leafBottom = leafCenterY - leafHeight / 2 - const leafTop = leafCenterY + leafHeight / 2 addBox(mesh, baseMaterial, hingeW, hingeH, hingeD, hingeMarkerX, leafBottom + 0.25, 0) addBox(mesh, baseMaterial, hingeW, hingeH, hingeD, hingeMarkerX, (leafBottom + leafTop) / 2, 0) addBox(mesh, baseMaterial, hingeW, hingeH, hingeD, hingeMarkerX, leafTop - 0.25, 0) @@ -1401,6 +1853,15 @@ function addGarageTiltupDoor( addBox(mesh, revealMaterial, insideWidth, 0.026, Math.max(frameDepth * 0.4, 0.035), 0, hingeY, 0) } +function getEffectiveOpeningShape(node: DoorNode): DoorNode['openingShape'] { + return node.doorType === 'folding' || + node.doorType === 'pocket' || + node.doorType === 'barn' || + node.doorType === 'sliding' + ? 'rectangle' + : (node.openingShape ?? 'rectangle') +} + function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) { // Root mesh is an invisible hitbox; all visuals live in child meshes mesh.geometry.dispose() @@ -1422,7 +1883,7 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) { width, height, openingKind, - openingShape, + openingShape: rawOpeningShape, frameThickness, frameDepth, threshold, @@ -1444,6 +1905,7 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) { slideDirection = 'left', garagePanelCount = 4, } = node + const openingShape = getEffectiveOpeningShape(node) ?? rawOpeningShape const runtimeDoorState = useInteractive.getState().doors[node.id] const swingAngle = runtimeDoorState?.swingAngle ?? nodeSwingAngle const operationState = runtimeDoorState?.operationState ?? nodeOperationState @@ -1662,6 +2124,15 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) { }) } else if (doorType === 'double' || doorType === 'french') { const doubleLeafW = insideWidth / 2 + const fullLeafTopRadii = getDoorTopRadii(node, insideWidth, leafH) + const roundedBoundary = + openingShape === 'rounded' + ? { + fullLeft: -insideWidth / 2, + fullRight: insideWidth / 2, + radii: fullLeafTopRadii, + } + : undefined addDoorLeaf(mesh, { leafWidth: doubleLeafW, leafHeight: leafH, @@ -1681,6 +2152,14 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) { panicBar, panicBarHeight, doorHeight: height, + openingShape, + openingTopRadii: + openingShape === 'rounded' + ? { topLeft: fullLeafTopRadii.topLeft, topRight: 0 } + : fullLeafTopRadii, + archHeight: node.archHeight ?? 0.45, + roundedBoundary, + archOuterSide: openingShape === 'arch' ? 'left' : undefined, }) addDoorLeaf(mesh, { leafWidth: doubleLeafW, @@ -1701,6 +2180,14 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) { panicBar, panicBarHeight, doorHeight: height, + openingShape, + openingTopRadii: + openingShape === 'rounded' + ? { topLeft: 0, topRight: fullLeafTopRadii.topRight } + : fullLeafTopRadii, + archHeight: node.archHeight ?? 0.45, + roundedBoundary, + archOuterSide: openingShape === 'arch' ? 'right' : undefined, }) } else { const hingeX = hingesSide === 'right' ? insideWidth / 2 : -insideWidth / 2 @@ -1724,6 +2211,9 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) { panicBar, panicBarHeight, doorHeight: height, + openingShape, + openingTopRadii: getDoorTopRadii(node, insideWidth, leafH), + archHeight: node.archHeight ?? 0.45, }) } @@ -1739,7 +2229,8 @@ function syncDoorCutout(node: DoorNode, mesh: THREE.Mesh) { mesh.add(cutout) } cutout.geometry.dispose() - if (node.openingShape === 'arch') { + const openingShape = getEffectiveOpeningShape(node) + if (openingShape === 'arch') { cutout.geometry = new THREE.ExtrudeGeometry( createArchShape( -node.width / 2, @@ -1755,7 +2246,7 @@ function syncDoorCutout(node: DoorNode, mesh: THREE.Mesh) { }, ) cutout.geometry.translate(0, 0, -0.5) - } else if (node.openingShape === 'rounded') { + } else if (openingShape === 'rounded') { cutout.geometry = new THREE.ExtrudeGeometry( createRoundedTopShape( -node.width / 2, diff --git a/packages/viewer/src/systems/elevator/elevator-interaction-system.tsx b/packages/viewer/src/systems/elevator/elevator-interaction-system.tsx index ded3d4999..2b5699dba 100644 --- a/packages/viewer/src/systems/elevator/elevator-interaction-system.tsx +++ b/packages/viewer/src/systems/elevator/elevator-interaction-system.tsx @@ -1,8 +1,8 @@ import { type AnyNodeId, openElevatorDoor, - resolveElevatorDispatchTarget, requestElevatorLevel, + resolveElevatorDispatchTarget, useInteractive, useScene, } from '@pascal-app/core' diff --git a/packages/viewer/src/systems/roof/roof-system.tsx b/packages/viewer/src/systems/roof/roof-system.tsx index 9fab9f7cc..856bc24f3 100644 --- a/packages/viewer/src/systems/roof/roof-system.tsx +++ b/packages/viewer/src/systems/roof/roof-system.tsx @@ -44,6 +44,9 @@ const _quaternion = new THREE.Quaternion() const _scale = new THREE.Vector3(1, 1, 1) const _yAxis = new THREE.Vector3(0, 1, 0) const _uvFaceNormal = new THREE.Vector3() +const _uvWorldDown = new THREE.Vector3(0, -1, 0) +const _uvDownSlope = new THREE.Vector3() +const _uvAcrossSlope = new THREE.Vector3() // Pending merged-roof updates carried across frames (for throttling) const pendingRoofUpdates = new Set() @@ -126,15 +129,19 @@ export const RoofSystem = () => { pendingRoofUpdates.delete(id) continue } + const group = sceneRegistry.nodes.get(id) as THREE.Group - if (group) { - const mergedMesh = group.getObjectByName('merged-roof') as THREE.Mesh | undefined - if (mergedMesh?.visible !== false) { - // Only rebuild when visible — RoofEditSystem re-triggers via markDirty on edit mode exit - updateMergedRoofGeometry(node as RoofNode, group, nodes) - roofsProcessed++ - } + if (!group) continue + + const mergedMesh = group.getObjectByName('merged-roof') as THREE.Mesh | undefined + if (!mergedMesh) continue + + if (mergedMesh.visible !== false) { + // Only rebuild when visible — RoofEditSystem re-triggers via markDirty on edit mode exit + updateMergedRoofGeometry(node as RoofNode, group, nodes) + roofsProcessed++ } + pendingRoofUpdates.delete(id) } }, 5) // Priority 5: run after all other systems have settled @@ -964,6 +971,28 @@ function createGeometryFromFaces( const vA = new THREE.Vector3().subVectors(p1, p0) const vB = new THREE.Vector3().subVectors(p2, p0) const normal = new THREE.Vector3().crossVectors(vA, vB).normalize() + let slopeAlignedDown: THREE.Vector3 | null = null + let slopeAlignedAcross: THREE.Vector3 | null = null + let slopeAlignedVOrigin = 0 + + if (normal.y > SHINGLE_SURFACE_EPSILON) { + _uvDownSlope.copy(_uvWorldDown).projectOnPlane(normal) + if (_uvDownSlope.lengthSq() > 1e-8) { + _uvDownSlope.normalize() + _uvAcrossSlope.crossVectors(_uvDownSlope, normal).normalize() + + let highestPoint = face[0]! + for (const candidate of face) { + if (candidate.y > highestPoint.y) { + highestPoint = candidate + } + } + + slopeAlignedDown = _uvDownSlope.clone() + slopeAlignedAcross = _uvAcrossSlope.clone() + slopeAlignedVOrigin = highestPoint.dot(slopeAlignedDown) + } + } let assignedMatIndex = 0 if (typeof matRule === 'function') { @@ -989,9 +1018,15 @@ function createGeometryFromFaces( normals.push(normal.x, normal.y, normal.z) normals.push(normal.x, normal.y, normal.z) - pushRoofUv(uvs, p0, normal) - pushRoofUv(uvs, fi, normal) - pushRoofUv(uvs, fi1, normal) + if (slopeAlignedDown && slopeAlignedAcross) { + uvs.push(p0.dot(slopeAlignedAcross), slopeAlignedVOrigin - p0.dot(slopeAlignedDown)) + uvs.push(fi.dot(slopeAlignedAcross), slopeAlignedVOrigin - fi.dot(slopeAlignedDown)) + uvs.push(fi1.dot(slopeAlignedAcross), slopeAlignedVOrigin - fi1.dot(slopeAlignedDown)) + } else { + pushRoofUv(uvs, p0, normal) + pushRoofUv(uvs, fi, normal) + pushRoofUv(uvs, fi1, normal) + } indices.push(vertexCount, vertexCount + 1, vertexCount + 2) @@ -1036,12 +1071,22 @@ function pushRoofUv(uvs: number[], point: THREE.Vector3, normal: THREE.Vector3) return } + if (_uvFaceNormal.y > SHINGLE_SURFACE_EPSILON) { + _uvDownSlope.copy(_uvWorldDown).projectOnPlane(_uvFaceNormal) + if (_uvDownSlope.lengthSq() > 1e-8) { + _uvDownSlope.normalize() + _uvAcrossSlope.crossVectors(_uvDownSlope, _uvFaceNormal).normalize() + uvs.push(point.dot(_uvAcrossSlope), point.dot(_uvDownSlope)) + return + } + } + if (absX >= absZ) { - uvs.push(point.z, point.y) + uvs.push(_uvFaceNormal.x >= 0 ? point.z : -point.z, -point.y) return } - uvs.push(point.x, point.y) + uvs.push(_uvFaceNormal.z >= 0 ? point.x : -point.x, -point.y) } function ensureUv2Attribute(geometry: THREE.BufferGeometry) { diff --git a/packages/viewer/src/systems/stair/stair-system.tsx b/packages/viewer/src/systems/stair/stair-system.tsx index 99601d6af..ff4be3b74 100644 --- a/packages/viewer/src/systems/stair/stair-system.tsx +++ b/packages/viewer/src/systems/stair/stair-system.tsx @@ -6,11 +6,9 @@ import { type StairSegmentNode, sceneRegistry, spatialGridManager, - syncAutoStairOpenings, useScene, } from '@pascal-app/core' import { useFrame } from '@react-three/fiber' -import { useEffect, useRef } from 'react' import * as THREE from 'three' import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js' @@ -30,26 +28,6 @@ export const StairSystem = () => { const dirtyNodes = useScene((state) => state.dirtyNodes) const clearDirty = useScene((state) => state.clearDirty) const rootNodeIds = useScene((state) => state.rootNodeIds) - const syncingAutoOpeningsRef = useRef(false) - - useEffect(() => { - const applyUpdates = (updates: ReturnType) => { - if (updates.length === 0) return - syncingAutoOpeningsRef.current = true - useScene.getState().updateNodes(updates) - queueMicrotask(() => { - syncingAutoOpeningsRef.current = false - }) - } - - applyUpdates(syncAutoStairOpenings(useScene.getState().nodes)) - - return useScene.subscribe((state, prevState) => { - if (syncingAutoOpeningsRef.current) return - if (state.nodes === prevState.nodes) return - applyUpdates(syncAutoStairOpenings(state.nodes)) - }) - }, []) useFrame(() => { if (rootNodeIds.length === 0) { diff --git a/packages/viewer/src/systems/window/window-system.tsx b/packages/viewer/src/systems/window/window-system.tsx index c4657ef2f..d9254566d 100644 --- a/packages/viewer/src/systems/window/window-system.tsx +++ b/packages/viewer/src/systems/window/window-system.tsx @@ -42,7 +42,10 @@ export const WindowSystem = () => { clearDirty(id as AnyNodeId) // Rebuild the parent wall so its cutout reflects the updated window geometry - if ((node as WindowNode).parentId) { + // Avoid triggering expensive wall CSG rebuilds while the window is being interactively moved/duplicated. + // The editor tools will request a final wall rebuild on commit. + const isTransient = !!(node.metadata as Record | null)?.isTransient + if (!isTransient && (node as WindowNode).parentId) { useScene.getState().dirtyNodes.add((node as WindowNode).parentId as AnyNodeId) } })