Merge remote-tracking branch 'origin/main' into fix/fri-8-may

# Conflicts:
#	packages/editor/src/components/tools/fence/move-fence-endpoint-tool.tsx
#	packages/editor/src/components/tools/select/box-select-tool.tsx
#	packages/editor/src/components/tools/wall/move-wall-tool.tsx
#	packages/editor/src/components/ui/panels/column-panel.tsx
#	packages/editor/src/components/ui/panels/door-panel.tsx
#	packages/editor/src/components/ui/panels/window-panel.tsx
#	packages/viewer/src/components/renderers/column/column-renderer.tsx
#	packages/viewer/src/components/viewer/index.tsx
#	packages/viewer/src/systems/slab/slab-system.tsx
This commit is contained in:
sudhir
2026-05-12 00:27:06 +05:30
241 changed files with 6976 additions and 5242 deletions
+97
View File
@@ -0,0 +1,97 @@
---
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.
allowed-tools: Bash(git *) Bash(gh *) Read
---
Open a pull request against `pascalorg/editor` from the current branch.
## 1. Pre-flight
```bash
git status # confirm working tree state
git branch --show-current # confirm we're on a feature branch, not main
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.
Run a build sanity check if the change is non-trivial:
```bash
bun typecheck
bun build
```
Don't open 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.
```bash
cat .github/pull_request_template.md
```
Mirror the template 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.
## 3. Push and open
```bash
git push -u origin HEAD
```
Check for an existing PR first:
```bash
gh pr view --json url 2>/dev/null
```
If none exists, create it. Pass the body via HEREDOC to preserve markdown formatting:
```bash
gh pr create --title "short, scope-prefixed title" --body "$(cat <<'EOF'
## What does this PR do?
<one-paragraph description; link issues>
## How to test
1. <step>
2. <step>
3. <step>
## Screenshots / screen recording
<link or "N/A — non-visual change">
## Checklist
- [x] I've tested this locally with `bun dev`
- [x] My code follows the existing code style (run `bun check` to verify)
- [ ] I've updated relevant documentation (if applicable)
- [x] This PR targets the `main` branch
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.
## 4. Report
Return:
- PR URL
- Title used
- Local typecheck/build status (if you ran them)
- A note for the reviewer if anything in the checklist is left unchecked
@@ -10,16 +10,16 @@ Architectural review for Pascal PRs. The user will provide a PR URL, branch name
Read these before reviewing any diff. They are the source of truth, not your training data: Read these before reviewing any diff. They are the source of truth, not your training data:
- `.claude/rules/systems.md` — core systems vs viewer systems, what each may do - `wiki/architecture/systems.md` — core systems vs viewer systems, what each may do
- `.claude/rules/renderers.md` — renderer responsibilities and prohibitions - `wiki/architecture/renderers.md` — renderer responsibilities and prohibitions
- `.claude/rules/tools.md` — editor tools live only in `apps/editor/components/tools/` - `wiki/architecture/tools.md` — editor tools live only in `apps/editor/components/tools/`
- `.claude/rules/viewer-isolation.md` — viewer must stay editor-agnostic - `wiki/architecture/viewer-isolation.md` — viewer must stay editor-agnostic
- `.claude/rules/layers.md` - `wiki/architecture/layers.md`
- `.claude/rules/selection-managers.md` - `wiki/architecture/selection-managers.md`
- `.claude/rules/scene-registry.md` - `wiki/architecture/scene-registry.md`
- `.claude/rules/spatial-queries.md` - `wiki/architecture/spatial-queries.md`
- `.claude/rules/node-schemas.md` - `wiki/architecture/node-schemas.md`
- `.claude/rules/events.md` - `wiki/architecture/events.md`
Only the first four are required on every review; read the rest when the diff touches their subject area. Only the first four are required on every review; read the rest when the diff touches their subject area.
@@ -73,7 +73,7 @@ Write the classification down before writing findings. If core gains "Floorplan"
- `packages/core/**` does not import Three.js, react-three-fiber, or anything from `packages/viewer` / `apps/editor`. - `packages/core/**` does not import Three.js, react-three-fiber, or anything from `packages/viewer` / `apps/editor`.
- `packages/core/**` does not introduce types or helpers named after an editor view (`Floorplan*`, `Paint*`, `Draft*`). Generic plan-geometry helpers are fine; view-specific vocabulary is not. - `packages/core/**` does not introduce types or helpers named after an editor view (`Floorplan*`, `Paint*`, `Draft*`). Generic plan-geometry helpers are fine; view-specific vocabulary is not.
- Renderers contain no geometry generation or domain logic — that belongs in a system. - Renderers contain no geometry generation or domain logic — that belongs in a system.
- Tools mutate `useScene` (committed state) and `useLiveTransforms` (ephemeral drag state); direct `sceneRegistry` mesh transforms are allowed only under the live-drag exception in `.claude/rules/tools.md`. No business logic, no imports from `packages/viewer`. - Tools mutate `useScene` (committed state) and `useLiveTransforms` (ephemeral drag state); direct `sceneRegistry` mesh transforms are allowed only under the live-drag exception in `wiki/architecture/tools.md`. No business logic, no imports from `packages/viewer`.
### B. Hook hygiene (`useEditor`, `useScene`, `useViewer`) ### B. Hook hygiene (`useEditor`, `useScene`, `useViewer`)
- Stores hold state + setters only. No business logic, side effects, async work, or derived computations inside the store definition. - Stores hold state + setters only. No business logic, side effects, async work, or derived computations inside the store definition.
@@ -95,7 +95,7 @@ Write the classification down before writing findings. If core gains "Floorplan"
Group findings by severity: Group findings by severity:
- **Blocker** — violates a rule in `.claude/rules` or breaks a layer boundary. Must be fixed before merge. - **Blocker** — violates a rule in `wiki/architecture/` or breaks a layer boundary. Must be fixed before merge.
- **Suggestion** — likely problem, worth discussing. Not a hard block. - **Suggestion** — likely problem, worth discussing. Not a hard block.
- **Nit** — minor, optional. - **Nit** — minor, optional.
@@ -103,7 +103,7 @@ For each finding, include:
1. File and line: `path/to/file.ts:42` 1. File and line: `path/to/file.ts:42`
2. The offending snippet (short — 15 lines) 2. The offending snippet (short — 15 lines)
3. The rule it violates, linked to the rule file (e.g. `.claude/rules/viewer-isolation.md`) 3. The rule it violates, linked to the wiki page (e.g. `wiki/architecture/viewer-isolation.md`)
4. A concrete proposed fix 4. A concrete proposed fix
Skip formatting, import ordering, and anything CI already covers. Skip formatting, import ordering, and anything CI already covers.
-1
View File
@@ -1 +0,0 @@
../../.cursor/rules/creating-rules.mdc
-1
View File
@@ -1 +0,0 @@
../../.cursor/rules/events.mdc
-1
View File
@@ -1 +0,0 @@
../../.cursor/rules/layers.mdc
-1
View File
@@ -1 +0,0 @@
../../.cursor/rules/node-schemas.mdc
-1
View File
@@ -1 +0,0 @@
../../.cursor/rules/renderers.mdc
-1
View File
@@ -1 +0,0 @@
../../.cursor/rules/scene-registry.mdc
-1
View File
@@ -1 +0,0 @@
../../.cursor/rules/selection-managers.mdc
-1
View File
@@ -1 +0,0 @@
../../.cursor/rules/spatial-queries.mdc
-1
View File
@@ -1 +0,0 @@
../../.cursor/rules/systems.mdc
-1
View File
@@ -1 +0,0 @@
../../.cursor/rules/tools.mdc
-1
View File
@@ -1 +0,0 @@
../../.cursor/rules/viewer-isolation.mdc
+1
View File
@@ -0,0 +1 @@
../.agents/skills
-1
View File
@@ -1 +0,0 @@
../../.cursor/rules/creating-rules.mdc
-1
View File
@@ -1 +0,0 @@
../../.cursor/rules/events.mdc
-1
View File
@@ -1 +0,0 @@
../../.cursor/rules/layers.mdc
-1
View File
@@ -1 +0,0 @@
../../.cursor/rules/node-schemas.mdc
-1
View File
@@ -1 +0,0 @@
../../.cursor/rules/renderers.mdc
-1
View File
@@ -1 +0,0 @@
../../.cursor/rules/scene-registry.mdc
-1
View File
@@ -1 +0,0 @@
../../.cursor/rules/selection-managers.mdc
-1
View File
@@ -1 +0,0 @@
../../.cursor/rules/spatial-queries.mdc
-1
View File
@@ -1 +0,0 @@
../../.cursor/rules/systems.mdc
-1
View File
@@ -1 +0,0 @@
../../.cursor/rules/tools.mdc
-1
View File
@@ -1 +0,0 @@
../../.cursor/rules/viewer-isolation.mdc
+1
View File
@@ -0,0 +1 @@
../.agents/skills
-119
View File
@@ -1,119 +0,0 @@
---
name: review-architecture
description: Review a PR against the Pascal architectural rules — layer boundaries (core/viewer/editor), systems/renderers/tools separation, hook hygiene (useEditor/useScene/useViewer), and selector performance. Use when the user asks to review a PR, audit a branch, or check that changes respect the codebase's architecture.
allowed-tools: Bash(git *) Bash(gh *) Read Grep Glob
---
Architectural review for Pascal PRs. The user will provide a PR URL, branch name, or ask to review the current branch.
## 1. Load the rules (required — do not skip)
Read these before reviewing any diff. They are the source of truth, not your training data:
- `.codex/rules/systems.md` — core systems vs viewer systems, what each may do
- `.codex/rules/renderers.md` — renderer responsibilities and prohibitions
- `.codex/rules/tools.md` — editor tools live only in `apps/editor/components/tools/`
- `.codex/rules/viewer-isolation.md` — viewer must stay editor-agnostic
- `.codex/rules/layers.md`
- `.codex/rules/selection-managers.md`
- `.codex/rules/scene-registry.md`
- `.codex/rules/spatial-queries.md`
- `.codex/rules/node-schemas.md`
- `.codex/rules/events.md`
Only the first four are required on every review; read the rest when the diff touches their subject area.
## 2. Fetch the diff
```bash
# If the user gave a PR URL or number:
gh pr diff <pr-number-or-url>
# If reviewing the current branch:
git diff main...HEAD
```
Also list changed files so you can map each to the relevant rule:
```bash
gh pr view <pr> --json files --jq '.files[].path'
# or
git diff --name-only main...HEAD
```
## 3. Layer classification — do this BEFORE the checklist
For every new file, new type, new store field, or new exported helper introduced by the diff, answer one question: **which layer does this belong to — core, viewer, or editor?** If the answer is "editor" but the code lives in `packages/core` or `packages/viewer` (or vice versa), flag it as a **blocker**. This is the most common and most damaging class of violation, and the checklist below won't reliably catch it on its own — do this pass explicitly.
### The three layers and what they own
**`packages/core` — domain data + pure logic.**
Owns: node schemas, the scene store (`useScene`), live transforms store, core systems (wall mitering, slab polygons, space detection), event bus, plain 2D/3D math helpers, `sceneRegistry`. Consumed by every downstream package, including read-only embeds. Must not know about: Three.js/R3F, `packages/viewer`, `apps/editor`, any rendering or UI concept, any tool/mode/phase concept, or any *view*-specific concept (floorplan, paint preview, cursor indicators, selection outline styling, etc.).
**`packages/viewer` — the 3D canvas, shippable standalone.**
Owns: `<Viewer>`, renderers, viewer systems (cutouts, zones, level positions, scans), the viewer store (`useViewer`) *for genuine presentation state only* (selection path, camera/level/wall/view modes, theme, display toggles, hover id). Consumed by both the editor and the read-only `/viewer/[id]` route. Must not know about: editor state (`useEditor`, tools, phases, modes), editor-only names baked into presentation modes (`'delete'`, `'paint-ready'`), editor-only state types (material preview, active paint target, floorplan anything).
**`apps/editor` (and editor-scoped packages) — the editing experience.**
Owns: tools, `useEditor`, action menus, panels, the floorplan panel and its helpers, paint mode, selection-manager phase/mode logic, cursor badges, command palette, keyboard shortcuts — anything absent from the read-only viewer route. Injects itself into `<Viewer>` via children and props, never the reverse.
### Five triggers that mean "this is probably editor"
1. **Would the read-only `/viewer/[id]` route need this?** If no, it belongs in `apps/editor`.
2. **Does the name contain an editor-specific word?** (`Floorplan`, `Paint…`, `Draft…`, `Marquee`, `CursorBadge`, `HoverMode`, `…Tool`, `Moving…`, `Curving…`.) Default to editor and justify loudly if it's anywhere else.
3. **Does the type or field reference a tool/mode/phase vocabulary?** (`'delete'`, `'paint-ready'`, `'material-paint'`, `'site'`/`'structure'`/`'furnish'`, `'build'`/`'edit'`.) Belongs in `useEditor`, not `useViewer` or core.
4. **Does the helper compute something only a 2D editor view needs?** (Floorplan transforms, measurement offsets, SVG path builders, marquee bounds scoped to floorplan.) Editor. Generic 2D geometry that any view could use (polygon math, rotation, clamping, line thickening) can live in core *as long as its names are generic* — no `Floorplan` prefix.
5. **Does a new store field have a setter that no part of the target layer ever calls?** (e.g. `setMaterialPreview` in `useViewer` that only the editor would ever invoke.) That's a layering smell — the state belongs in the caller's layer.
Write the classification down before writing findings. If core gains "Floorplan" types, or the viewer gains paint-mode vocabulary, or a renderer grows editor awareness — those are the blockers to lead with, not downstream symptoms.
## 4. Review checklist
### A. Layer boundaries
- `packages/viewer/**` does not import from `apps/editor` or reference `useEditor`, tool state, phase, or mode.
- `packages/core/**` does not import Three.js, react-three-fiber, or anything from `packages/viewer` / `apps/editor`.
- `packages/core/**` does not introduce types or helpers named after an editor view (`Floorplan*`, `Paint*`, `Draft*`). Generic plan-geometry helpers are fine; view-specific vocabulary is not.
- Renderers contain no geometry generation or domain logic — that belongs in a system.
- Tools mutate `useScene` (committed state) and `useLiveTransforms` (ephemeral drag state); direct `sceneRegistry` mesh transforms are allowed only under the live-drag exception in `.codex/rules/tools.md`. No business logic, no imports from `packages/viewer`.
### B. Hook hygiene (`useEditor`, `useScene`, `useViewer`)
- Stores hold state + setters only. No business logic, side effects, async work, or derived computations inside the store definition.
- Derived values belong in selectors or systems, not in the store body.
- No cross-store coupling: a store's action should not call another store's actions inside itself.
- New state added to `useViewer` must be presentation-only (selection, camera, level mode, display toggles). Editor-only state (active tool, phase, edit mode, paint preview, floorplan state) goes in `useEditor`.
### C. Selector performance
- Top-level components (pages, layouts, providers, `<Viewer>` siblings) must not subscribe to large or frequently-changing slices — e.g. `useScene(s => s.nodes)`, `useScene(s => s)`. Flag these: they re-render the whole subtree on every mutation.
- Selectors that return new object or array references each call (e.g. `s => ({ a: s.a, b: s.b })`, `s => s.items.filter(...)`) without a custom equality function (shallow or custom) are re-render hazards.
- Prefer subscribing by ID deep in the tree (one node per renderer) over subscribing to the full collection high up.
### D. Separation of concerns
- Viewer and core stay unaware of editor-specific concepts (tools, phases, active modes, editor UI state, view-specific helpers).
- Editor-only overlays and systems are injected as children of `<Viewer>`, not added inside the viewer package.
- New node types added correctly: schema → core system (if derived geometry) → viewer renderer → register in `NodeRenderer`.
## 5. Output format
Group findings by severity:
- **Blocker** — violates a rule in `.codex/rules` or breaks a layer boundary. Must be fixed before merge.
- **Suggestion** — likely problem, worth discussing. Not a hard block.
- **Nit** — minor, optional.
For each finding, include:
1. File and line: `path/to/file.ts:42`
2. The offending snippet (short — 15 lines)
3. The rule it violates, linked to the rule file (e.g. `.codex/rules/viewer-isolation.md`)
4. A concrete proposed fix
Skip formatting, import ordering, and anything CI already covers.
If the PR fully complies, say so explicitly — do not invent nits to appear thorough.
## 6. Final summary
End with:
- Blocker count, suggestion count, nit count
- One-sentence verdict: ready to merge / needs changes / needs discussion
- If blockers exist, list the files the author should open first
-79
View File
@@ -1,79 +0,0 @@
---
description: How to create and maintain project rules
globs: .cursor/rules/**
alwaysApply: false
---
# Creating Rules
Rules live in two places and are kept in sync via symlinks:
- `.cursor/rules/<rule-name>.mdc` — source of truth (Cursor format)
- `.claude/rules/<rule-name>.md` — symlink pointing to the cursor file
## Workflow
**1. Write the rule in `.cursor/rules/`**
```
.cursor/rules/my-rule.mdc
```
**2. Create a symlink in `.claude/rules/`**
```bash
ln -s ../../.cursor/rules/my-rule.mdc .claude/rules/my-rule.md
```
The `../../` prefix is required because the symlink lives two levels deep.
**3. Verify**
```bash
ls -la .claude/rules/my-rule.md
# → .claude/rules/my-rule.md -> ../../.cursor/rules/my-rule.mdc
```
## Rule File Format
```markdown
---
description: One-line summary of what this rule covers
globs:
alwaysApply: false
---
# Rule Title
Short intro paragraph.
## Section
Concrete guidance with examples.
```
- Set `alwaysApply: true` only for rules that apply to every file in the project.
- Use `globs` to scope a rule to specific paths (e.g. `packages/viewer/**`).
## Good Practices
- Keep rules under 500 lines. Split large rules into smaller focused files.
- Include concrete examples or reference real files with `@filename`.
- Add a rule when the same mistake has been made more than once — not preemptively.
- Prefer showing a correct example over listing prohibitions.
## Existing Rules
| Rule | Covers |
|---|---|
| `creating-rules` | This file — how to add rules |
| `renderers` | Node renderer pattern in `packages/viewer` |
| `systems` | Core and viewer systems architecture |
| `tools` | Editor tools structure in `apps/editor` |
| `viewer-isolation` | Keeping `@pascal-app/viewer` editor-agnostic |
| `scene-registry` | Global node ID → Object3D map and `useRegistry` |
| `selection-managers` | Two-layer selection (viewer + editor), events, outliner |
| `events` | Typed event bus — emitting and listening to node and grid events |
| `node-schemas` | Zod schema pattern for node types, createNode, updateNode |
| `spatial-queries` | Placement validation (canPlaceOnFloor/Wall/Ceiling) for tools |
| `layers` | Three.js layer constants, ownership, and rendering separation |
+1
View File
@@ -0,0 +1 @@
../.agents/skills
@@ -1 +0,0 @@
../../../.claude/skills/review-architecture/SKILL.md
+1
View File
@@ -0,0 +1 @@
../AGENTS.md
+100 -31
View File
@@ -10,7 +10,9 @@ on:
options: options:
- core - core
- viewer - viewer
- both - editor
- mcp
- all
bump: bump:
description: "Version bump" description: "Version bump"
required: true required: true
@@ -51,56 +53,115 @@ jobs:
git config user.name "github-actions[bot]" git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com" git config user.email "github-actions[bot]@users.noreply.github.com"
- name: Bump & publish core - name: Bump versions and sync inter-package references
if: inputs.package == 'core' || inputs.package == 'both' run: |
BUMP=${{ inputs.bump }}
TARGET=${{ inputs.package }}
bump_version() {
local v=$1
IFS='.' read -r MAJ MIN PAT <<< "$v"
if [ "$BUMP" = "major" ]; then MAJ=$((MAJ+1)); MIN=0; PAT=0; fi
if [ "$BUMP" = "minor" ]; then MIN=$((MIN+1)); PAT=0; fi
if [ "$BUMP" = "patch" ]; then PAT=$((PAT+1)); fi
echo "$MAJ.$MIN.$PAT"
}
# Track new versions in shell-local vars.
# NOTE: $GITHUB_ENV writes don't surface within the same step, so the
# peerDeps sync below must use shell vars, not env indirection.
declare -A NEW_VERSIONS
for pkg in core viewer editor mcp; do
if [ "$TARGET" = "$pkg" ] || [ "$TARGET" = "all" ]; then
CUR=$(jq -r '.version' packages/$pkg/package.json)
NEW=$(bump_version "$CUR")
jq --arg v "$NEW" '.version = $v' packages/$pkg/package.json > tmp.json && mv tmp.json packages/$pkg/package.json
NEW_VERSIONS[$pkg]=$NEW
UPPER=$(echo "$pkg" | tr '[:lower:]' '[:upper:]')
# Also export for the publish/commit/tag steps that follow.
echo "${UPPER}_VERSION=$NEW" >> $GITHUB_ENV
echo "Bumped @pascal-app/$pkg: $CUR → $NEW"
fi
done
# Sync inter-package references in peerDependencies and devDependencies.
# Anything that references a bumped @pascal-app/* package is updated to ^NEW.
for pkg in core viewer editor mcp; do
FILE=packages/$pkg/package.json
for dep in core viewer editor mcp; do
VAL="${NEW_VERSIONS[$dep]}"
[ -z "$VAL" ] && continue
jq --arg name "@pascal-app/$dep" --arg v "^$VAL" '
if .peerDependencies[$name] then .peerDependencies[$name] = $v else . end
| if .devDependencies[$name] then .devDependencies[$name] = $v else . end
' "$FILE" > tmp.json && mv tmp.json "$FILE"
done
done
echo "=== @pascal-app/* refs after sync ==="
for pkg in core viewer editor mcp; do
echo "--- packages/$pkg/package.json ---"
jq '{ peerDependencies: (.peerDependencies // {} | with_entries(select(.key | startswith("@pascal-app/")))), devDependencies: (.devDependencies // {} | with_entries(select(.key | startswith("@pascal-app/")))) }' packages/$pkg/package.json
done
- name: Build & publish core
if: inputs.package == 'core' || inputs.package == 'all'
working-directory: packages/core working-directory: packages/core
env: env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: | run: |
BUMP=${{ inputs.bump }}
VERSION=$(jq -r '.version' package.json)
IFS='.' read -r MAJ MIN PAT <<< "$VERSION"
if [ "$BUMP" = "major" ]; then MAJ=$((MAJ+1)); MIN=0; PAT=0; fi
if [ "$BUMP" = "minor" ]; then MIN=$((MIN+1)); PAT=0; fi
if [ "$BUMP" = "patch" ]; then PAT=$((PAT+1)); fi
VERSION="$MAJ.$MIN.$PAT"
jq --arg v "$VERSION" '.version = $v' package.json > tmp.json && mv tmp.json package.json
echo "CORE_VERSION=$VERSION" >> $GITHUB_ENV
bun run build bun run build
if [ "${{ inputs.dry-run }}" = "true" ]; then if [ "${{ inputs.dry-run }}" = "true" ]; then
echo "🏜️ Dry run — would publish @pascal-app/core@$VERSION" echo "🏜️ Dry run — would publish @pascal-app/core@$CORE_VERSION"
npm publish --dry-run --access public npm publish --dry-run --access public
else else
npm publish --access public npm publish --access public
echo "📦 Published @pascal-app/core@$VERSION" echo "📦 Published @pascal-app/core@$CORE_VERSION"
fi fi
- name: Bump & publish viewer - name: Build & publish viewer
if: inputs.package == 'viewer' || inputs.package == 'both' if: inputs.package == 'viewer' || inputs.package == 'all'
working-directory: packages/viewer working-directory: packages/viewer
env: env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: | run: |
BUMP=${{ inputs.bump }}
VERSION=$(jq -r '.version' package.json)
IFS='.' read -r MAJ MIN PAT <<< "$VERSION"
if [ "$BUMP" = "major" ]; then MAJ=$((MAJ+1)); MIN=0; PAT=0; fi
if [ "$BUMP" = "minor" ]; then MIN=$((MIN+1)); PAT=0; fi
if [ "$BUMP" = "patch" ]; then PAT=$((PAT+1)); fi
VERSION="$MAJ.$MIN.$PAT"
jq --arg v "$VERSION" '.version = $v' package.json > tmp.json && mv tmp.json package.json
echo "VIEWER_VERSION=$VERSION" >> $GITHUB_ENV
bun run build bun run build
if [ "${{ inputs.dry-run }}" = "true" ]; then if [ "${{ inputs.dry-run }}" = "true" ]; then
echo "🏜️ Dry run — would publish @pascal-app/viewer@$VERSION" echo "🏜️ Dry run — would publish @pascal-app/viewer@$VIEWER_VERSION"
npm publish --dry-run --access public npm publish --dry-run --access public
else else
npm publish --access public npm publish --access public
echo "📦 Published @pascal-app/viewer@$VERSION" echo "📦 Published @pascal-app/viewer@$VIEWER_VERSION"
fi
- name: Publish editor
if: inputs.package == 'editor' || inputs.package == 'all'
working-directory: packages/editor
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
if [ "${{ inputs.dry-run }}" = "true" ]; then
echo "🏜️ Dry run — would publish @pascal-app/editor@$EDITOR_VERSION"
npm publish --dry-run --access public
else
npm publish --access public
echo "📦 Published @pascal-app/editor@$EDITOR_VERSION"
fi
- name: Build & publish mcp
if: inputs.package == 'mcp' || inputs.package == 'all'
working-directory: packages/mcp
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
bun run build
if [ "${{ inputs.dry-run }}" = "true" ]; then
echo "🏜️ Dry run — would publish @pascal-app/mcp@$MCP_VERSION"
npm publish --dry-run --access public
else
npm publish --access public
echo "📦 Published @pascal-app/mcp@$MCP_VERSION"
fi fi
- name: Commit version bumps & tag - name: Commit version bumps & tag
@@ -118,6 +179,14 @@ jobs:
PKGS="$PKGS @pascal-app/viewer@$VIEWER_VERSION" PKGS="$PKGS @pascal-app/viewer@$VIEWER_VERSION"
TAGS="$TAGS @pascal-app/viewer@$VIEWER_VERSION" TAGS="$TAGS @pascal-app/viewer@$VIEWER_VERSION"
fi fi
if [ -n "$EDITOR_VERSION" ]; then
PKGS="$PKGS @pascal-app/editor@$EDITOR_VERSION"
TAGS="$TAGS @pascal-app/editor@$EDITOR_VERSION"
fi
if [ -n "$MCP_VERSION" ]; then
PKGS="$PKGS @pascal-app/mcp@$MCP_VERSION"
TAGS="$TAGS @pascal-app/mcp@$MCP_VERSION"
fi
git commit -m "release:${PKGS}" git commit -m "release:${PKGS}"
+35 -33
View File
@@ -1,49 +1,51 @@
# Pascal Agent Instructions # Agent Instructions — `pascalorg/editor`
This repository uses shared architecture rules for AI assistants. Treat the rule files as the source of truth for architecture-sensitive work. Public, open-source home of `@pascal-app/{core,viewer,editor,mcp}` and the standalone editor app. Consumed both as npm packages and (in `pascalorg/private-editor`) as a git submodule.
## Required Rule Sources ## Repo Shape
The canonical rules live in `.cursor/rules/*.mdc`. | Path | Purpose |
|---|---|
| `packages/core` | Scene graph, node schemas, stores, event bus, core systems — pure logic, no Three.js |
| `packages/viewer` | Standalone 3D canvas: renderers, viewer systems, presentation state |
| `packages/editor` | Editor UI components reused by the standalone app and embedders |
| `packages/mcp` | MCP server and scene storage adapters |
| `apps/editor` | Standalone editor app — composes `viewer` + `editor` + tools |
Claude-compatible paths are exposed in `.claude/rules/*.md`. ## Where to look
Codex-compatible paths are exposed in `.codex/rules/*.md`.
Both should point to the same Cursor rule sources so Claude and Codex review the exact same rules. - **Architecture rules** — `wiki/architecture/` (read on demand; index in `wiki/architecture/README.md`).
- **Skills (ready workflows)** — `.agents/skills/<name>/SKILL.md`. Same content is reachable as `.claude/skills/`, `.cursor/skills/`, `.codex/skills/` (symlinks to `.agents/skills/`).
- **Repo orientation for humans** — `README.md`, `SETUP.md`, `CONTRIBUTING.md`.
## Architecture Rules `CLAUDE.md`, `GEMINI.md`, and `.github/copilot-instructions.md` are symlinks to this file. Codex reads this file directly.
Read the relevant rules before making or reviewing changes in these areas: ## Layer Boundaries (read once, internalise)
- `.codex/rules/systems.md` — core systems vs viewer systems, what each may do - **`packages/core`** owns domain data and pure logic. It must not import Three.js, `packages/viewer`, `apps/editor`, rendering/UI concepts, tools, modes, phases, or view-specific concepts such as floorplan or paint preview.
- `.codex/rules/renderers.md` — renderer responsibilities and prohibitions - **`packages/viewer`** owns the standalone 3D canvas, renderers, viewer systems, and genuine presentation state. It must not know about `useEditor`, editor tools, phases, modes, paint mode, floorplan state, or editor-only presentation vocabulary.
- `.codex/rules/tools.md` — editor tools live only in `apps/editor/components/tools/` - **`apps/editor`** owns the editing experience: tools, `useEditor`, panels, floorplan helpers, paint mode, keyboard shortcuts, command palette, action menus, cursor badges, and editor-only overlays. Editor features are injected into `<Viewer>` via props and children.
- `.codex/rules/viewer-isolation.md` — viewer must stay editor-agnostic
- `.codex/rules/layers.md`
- `.codex/rules/selection-managers.md`
- `.codex/rules/scene-registry.md`
- `.codex/rules/spatial-queries.md`
- `.codex/rules/node-schemas.md`
- `.codex/rules/events.md`
For architecture reviews, the first four are always required. Read the remaining rules when the diff touches their subject area. Details, examples, and rationale live in `wiki/architecture/layers.md`, `wiki/architecture/viewer-isolation.md`, `wiki/architecture/systems.md`, `wiki/architecture/renderers.md`, `wiki/architecture/tools.md`.
## Layer Boundaries ## When making architecture-sensitive changes
`packages/core` owns domain data and pure logic. It must not import Three.js, `packages/viewer`, `apps/editor`, rendering/UI concepts, tools, modes, phases, or view-specific concepts such as floorplan or paint preview. Read the relevant page in `wiki/architecture/` **before** writing code. The page list lives in `wiki/architecture/README.md`. As a minimum:
`packages/viewer` owns the standalone 3D canvas, renderers, viewer systems, and genuine presentation state. It must not know about `useEditor`, editor tools, phases, modes, paint mode, floorplan state, or editor-only presentation vocabulary. - Adding a node type → `node-schemas.md`, `renderers.md`, `systems.md`
- Adding a tool → `tools.md`, `spatial-queries.md`, `events.md`
- Adding a system → `systems.md`, `scene-registry.md`
- Anything in `packages/viewer``viewer-isolation.md`, `layers.md`
- Anything touching selection → `selection-managers.md`, `scene-registry.md`, `events.md`
`apps/editor` owns the editing experience: tools, `useEditor`, panels, floorplan helpers, paint mode, keyboard shortcuts, command palette, action menus, cursor badges, and editor-only overlays. Editor features are injected into `<Viewer>` via props and children. ## When reviewing a PR
## Review Expectations Invoke the `review-architecture` skill (`.agents/skills/review-architecture/SKILL.md`). It loads the required architecture pages, fetches the diff, classifies each new file by layer, and reports findings grouped by severity.
When reviewing architecture changes: ## Operating rules
1. Classify every new file, type, store field, and exported helper as core, viewer, or editor before writing findings. - Read the full file before editing. Plan all changes, then make one complete edit.
2. Lead with layer-boundary blockers. - When the user corrects you, stop and re-read their message.
3. Check hook hygiene for `useEditor`, `useScene`, and `useViewer`. - After two consecutive tool failures, stop and change approach.
4. Check selector performance for broad subscriptions and selectors that allocate fresh references. - Don't introduce backwards-compatibility shims, dead code, or speculative abstractions.
5. Skip formatting and import ordering unless they hide a real behavior or architecture issue. - Don't write new comments unless they explain a non-obvious *why*.
Use `.codex/skills/review-architecture/SKILL.md` when the user asks Codex to review a PR, audit a branch, or check architecture compliance.
View File
+1
View File
@@ -412,6 +412,7 @@ npm publish --workspace=@pascal-app/viewer --access public
<a href="https://github.com/Aymericr"><img src="https://avatars.githubusercontent.com/u/4444492?v=4" width="60" height="60" alt="Aymeric Rabot" style="border-radius:50%"></a> <a href="https://github.com/Aymericr"><img src="https://avatars.githubusercontent.com/u/4444492?v=4" width="60" height="60" alt="Aymeric Rabot" style="border-radius:50%"></a>
<a href="https://github.com/wass08"><img src="https://avatars.githubusercontent.com/u/6551176?v=4" width="60" height="60" alt="Wassim Samad" style="border-radius:50%"></a> <a href="https://github.com/wass08"><img src="https://avatars.githubusercontent.com/u/6551176?v=4" width="60" height="60" alt="Wassim Samad" style="border-radius:50%"></a>
<a href="https://github.com/sudhir9297"><img src="https://avatars.githubusercontent.com/sudhir9297?v=4" width="60" height="60" alt="Sudhir" style="border-radius:50%"></a>
--- ---
+1 -1
View File
@@ -173,7 +173,7 @@ function parseIfMatch(raw: string | null): number | undefined {
const inner = match ? match[1] : trimmed const inner = match ? match[1] : trimmed
if (!inner) return undefined if (!inner) return undefined
const n = Number(inner) const n = Number(inner)
if (!Number.isFinite(n) || !Number.isInteger(n) || n < 0) return undefined if (!(Number.isFinite(n) && Number.isInteger(n)) || n < 0) return undefined
return n return n
} }
+1
View File
@@ -1,5 +1,6 @@
@import "tailwindcss"; @import "tailwindcss";
@import "tw-animate-css"; @import "tw-animate-css";
@import "../../../styles/elevation.css";
@source "../../../packages/editor/src"; @source "../../../packages/editor/src";
@custom-variant dark (&:is(.dark *)); @custom-variant dark (&:is(.dark *));
+26 -3
View File
@@ -1,13 +1,36 @@
'use client' 'use client'
import { Editor, type SidebarTab, ViewerToolbarLeft, ViewerToolbarRight } from '@pascal-app/editor' import {
Editor,
ItemsPanel,
type SidebarTab,
ViewerToolbarLeft,
ViewerToolbarRight,
} from '@pascal-app/editor'
import { Layers, Package, Settings } from 'lucide-react'
import Link from 'next/link' import Link from 'next/link'
const SIDEBAR_TABS: (SidebarTab & { component: React.ComponentType })[] = [ const SIDEBAR_TABS = [
{ {
id: 'site', id: 'site',
label: 'Scene', label: 'Scene',
component: () => null, // Built-in SitePanel handles this component: () => null,
mobileDefaultSnap: 0.5,
mobileIcon: <Layers className="h-5 w-5" />,
},
{
id: 'items',
label: 'Items',
component: ItemsPanel,
mobileDefaultSnap: 0.5,
mobileIcon: <Package className="h-5 w-5" />,
},
{
id: 'settings',
label: 'Settings',
component: () => null,
mobileDefaultSnap: 0.5,
mobileIcon: <Settings className="h-5 w-5" />,
}, },
] ]
+6 -27
View File
@@ -1,8 +1,8 @@
/** /**
* Environment variable validation for the editor app. * Environment variable validation for the editor app.
* *
* This file validates that required environment variables are set at runtime. * This file validates environment variables used by the standalone editor app.
* Variables are defined in the root .env file. * Values are loaded from the repo root .env.local by package scripts.
* *
* @see https://env.t3.gg/docs/nextjs * @see https://env.t3.gg/docs/nextjs
*/ */
@@ -13,42 +13,21 @@ export const env = createEnv({
/** /**
* Server-side environment variables (not exposed to client) * Server-side environment variables (not exposed to client)
*/ */
server: { server: {},
// Database
POSTGRES_URL: z.string().min(1),
SUPABASE_SERVICE_ROLE_KEY: z.string().min(1),
// Auth
BETTER_AUTH_SECRET: z.string().min(1),
GOOGLE_CLIENT_ID: z.string().optional(),
GOOGLE_CLIENT_SECRET: z.string().optional(),
// Email
RESEND_API_KEY: z.string().optional(),
},
/** /**
* Client-side environment variables (exposed to browser via NEXT_PUBLIC_) * Client-side environment variables (exposed to browser via NEXT_PUBLIC_)
*/ */
client: { client: {
NEXT_PUBLIC_SUPABASE_URL: z.string().min(1), NEXT_PUBLIC_ASSETS_CDN_URL: z.string().optional(),
NEXT_PUBLIC_SUPABASE_ANON_KEY: z.string().optional(),
}, },
/** /**
* Runtime values - pulls from process.env * Runtime values - pulls from process.env
*/ */
runtimeEnv: { runtimeEnv: {
// Server NEXT_PUBLIC_ASSETS_CDN_URL:
POSTGRES_URL: process.env.POSTGRES_URL, process.env.NEXT_PUBLIC_ASSETS_CDN_URL ?? process.env.NEXT_PUBLIC_EDITOR_ASSETS_CDN_URL,
SUPABASE_SERVICE_ROLE_KEY: process.env.SUPABASE_SERVICE_ROLE_KEY,
BETTER_AUTH_SECRET: process.env.BETTER_AUTH_SECRET,
GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID,
GOOGLE_CLIENT_SECRET: process.env.GOOGLE_CLIENT_SECRET,
RESEND_API_KEY: process.env.RESEND_API_KEY,
// Client
NEXT_PUBLIC_SUPABASE_URL: process.env.NEXT_PUBLIC_SUPABASE_URL,
NEXT_PUBLIC_SUPABASE_ANON_KEY: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY,
}, },
/** /**
+3
View File
@@ -1,6 +1,9 @@
import type { NextConfig } from 'next' import type { NextConfig } from 'next'
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
logging: {
browserToTerminal: true,
},
typescript: { typescript: {
ignoreBuildErrors: true, ignoreBuildErrors: true,
}, },
+5 -4
View File
@@ -4,8 +4,8 @@
"type": "module", "type": "module",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "dotenv -e ./.env.local --override -- next dev --port 3002", "dev": "dotenv -e ../../.env.local -- next dev --port 3002",
"build": "dotenv -e ./.env.local --override -- next build", "build": "dotenv -e ../../.env.local -- next build",
"start": "next start", "start": "next start",
"lint": "biome lint", "lint": "biome lint",
"check-types": "next typegen && tsc --noEmit" "check-types": "next typegen && tsc --noEmit"
@@ -21,6 +21,7 @@
"@tailwindcss/postcss": "^4.2.1", "@tailwindcss/postcss": "^4.2.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"geist": "^1.7.0", "geist": "^1.7.0",
"lucide-react": "^1.7.0",
"next": "16.2.1", "next": "16.2.1",
"postcss": "^8.5.6", "postcss": "^8.5.6",
"react": "^19.2.4", "react": "^19.2.4",
@@ -37,9 +38,9 @@
"@types/react": "19.2.2", "@types/react": "19.2.2",
"@types/react-dom": "19.2.2", "@types/react-dom": "19.2.2",
"agentation": "^2.3.2", "agentation": "^2.3.2",
"react-grab": "^0.1.25", "react-grab": "^0.1.29",
"react-scan": "^0.5.3", "react-scan": "^0.5.3",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"typescript": "5.9.3" "typescript": "6.0.2"
} }
} }
Binary file not shown.
+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="4"/>
<path d="M12 2v2M12 20v2M2 12h2M20 12h2"/>
<path d="M12 8v1M12 15v1M8 12h1M15 12h1"/>
</svg>

After

Width:  |  Height:  |  Size: 290 B

+9
View File
@@ -0,0 +1,9 @@
{
"$schema": "https://openapi.vercel.sh/vercel.json",
"buildCommand": "cd ../.. && npx -y bun@1.3.13 run build --filter=editor",
"installCommand": "cd ../.. && npx -y bun@1.3.13 install --frozen-lockfile",
"outputDirectory": ".next",
"cleanUrls": true,
"trailingSlash": false,
"bunVersion": "1.x"
}
+201 -242
View File
File diff suppressed because it is too large Load Diff
+21 -4
View File
@@ -12,23 +12,40 @@
"check:fix": "biome check --write", "check:fix": "biome check --write",
"check-types": "turbo run check-types", "check-types": "turbo run check-types",
"kill": "lsof -ti:3002 | xargs kill -9 2>/dev/null || echo 'No processes found on port 3002'", "kill": "lsof -ti:3002 | xargs kill -9 2>/dev/null || echo 'No processes found on port 3002'",
"release": "gh workflow run release.yml -f package=both -f bump=patch", "release": "gh workflow run release.yml -f package=all -f bump=patch",
"release:viewer": "gh workflow run release.yml -f package=viewer -f bump=patch", "release:viewer": "gh workflow run release.yml -f package=viewer -f bump=patch",
"release:core": "gh workflow run release.yml -f package=core -f bump=patch", "release:core": "gh workflow run release.yml -f package=core -f bump=patch",
"release:minor": "gh workflow run release.yml -f package=both -f bump=minor", "release:editor": "gh workflow run release.yml -f package=editor -f bump=patch",
"release:major": "gh workflow run release.yml -f package=both -f bump=major" "release:mcp": "gh workflow run release.yml -f package=mcp -f bump=patch",
"release:minor": "gh workflow run release.yml -f package=all -f bump=minor",
"release:major": "gh workflow run release.yml -f package=all -f bump=major"
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "^2.4.6", "@biomejs/biome": "^2.4.6",
"dotenv-cli": "^11.0.0", "dotenv-cli": "^11.0.0",
"turbo": "^2.8.15", "turbo": "^2.8.15",
"typescript": "5.9.3", "typescript": "6.0.2",
"ultracite": "^7.2.5" "ultracite": "^7.2.5"
}, },
"engines": { "engines": {
"node": ">=18" "node": ">=18"
}, },
"packageManager": "bun@1.3.0", "packageManager": "bun@1.3.0",
"overrides": {
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3",
"@types/three": "0.184.0"
},
"optionalDependencies": {
"@tailwindcss/oxide-darwin-arm64": "4.3.0",
"@tailwindcss/oxide-darwin-x64": "4.3.0",
"@tailwindcss/oxide-linux-arm64-gnu": "4.3.0",
"@tailwindcss/oxide-linux-x64-gnu": "4.3.0",
"lightningcss-darwin-arm64": "1.32.0",
"lightningcss-darwin-x64": "1.32.0",
"lightningcss-linux-arm64-gnu": "1.32.0",
"lightningcss-linux-x64-gnu": "1.32.0"
},
"workspaces": [ "workspaces": [
"apps/*", "apps/*",
"packages/*", "packages/*",
+4 -3
View File
@@ -1,6 +1,6 @@
{ {
"name": "@pascal-app/core", "name": "@pascal-app/core",
"version": "0.6.0", "version": "0.8.0",
"description": "Core library for Pascal 3D building editor", "description": "Core library for Pascal 3D building editor",
"type": "module", "type": "module",
"main": "./dist/index.js", "main": "./dist/index.js",
@@ -73,9 +73,10 @@
}, },
"devDependencies": { "devDependencies": {
"@pascal/typescript-config": "*", "@pascal/typescript-config": "*",
"@types/bun": "^1.3.0",
"@types/react": "^19.2.2", "@types/react": "^19.2.2",
"typescript": "5.9.3", "@types/three": "^0.184.0",
"@types/three": "^0.184.0" "typescript": "6.0.2"
}, },
"keywords": [ "keywords": [
"3d", "3d",
+10 -7
View File
@@ -28,12 +28,13 @@ export interface GridEvent {
/** World-space intersection point on the grid plane. */ /** World-space intersection point on the grid plane. */
position: [number, number, number] position: [number, number, number]
/** /**
* Building-local intersection point, relative to the currently selected building. * Building-local intersection point relative to the currently selected building.
* Equals `position` when no building is selected. * Equals `position` when no building is selected.
* Use this for placing or committing anything that lives inside a building * Use this for placing/committing anything that lives inside a building (walls, slabs, items, etc.).
* (walls, slabs, items, etc.).
*/ */
localPosition: [number, number, number] localPosition: [number, number, number]
faceIndex?: number
object: Object3D
nativeEvent: ThreeEvent<PointerEvent> nativeEvent: ThreeEvent<PointerEvent>
} }
@@ -66,7 +67,7 @@ export type StairSegmentEvent = NodeEvent<StairSegmentNode>
export type WindowEvent = NodeEvent<WindowNode> export type WindowEvent = NodeEvent<WindowNode>
export type DoorEvent = NodeEvent<DoorNode> export type DoorEvent = NodeEvent<DoorNode>
// Event suffixes, exported for use in hooks // Event suffixes - exported for use in hooks
export const eventSuffixes = [ export const eventSuffixes = [
'click', 'click',
'move', 'move',
@@ -99,7 +100,7 @@ export interface ThumbnailGenerateEvent {
/** /**
* When true, snap levels to their true positions before capturing (for a * When true, snap levels to their true positions before capturing (for a
* consistent auto-thumbnail angle) and defer the capture if the tab is * consistent auto-thumbnail angle) and defer the capture if the tab is
* hidden, the background auto-save path. Omit for user-driven captures * hidden the background auto-save path. Omit for user-driven captures
* that should fire immediately from the current camera pose. * that should fire immediately from the current camera pose.
*/ */
snapLevels?: boolean snapLevels?: boolean
@@ -107,8 +108,10 @@ export interface ThumbnailGenerateEvent {
export interface CameraControlFitSceneEvent { export interface CameraControlFitSceneEvent {
/** /**
* XZ-plane axis-aligned bounds for camera framing. Omitted values let the * XZ-plane axis-aligned bounds of the scene's geometry, computed from the
* listener choose its default framing pose. * scene graph (see `@pascal-app/editor`'s `computeSceneBoundsXZ`). The
* viewer's camera-controls listener frames the camera onto this box.
* Omitted values fall back to the camera's default pose.
*/ */
bounds?: { bounds?: {
min: [number, number] min: [number, number]
+5 -5
View File
@@ -6,10 +6,7 @@ export function insetPolygonFromCentroid(
return polygon.map(([x, z]) => [x, z] as [number, number]) return polygon.map(([x, z]) => [x, z] as [number, number])
} }
const centroid = polygon.reduce( const centroid = polygon.reduce((acc, [x, z]) => ({ x: acc.x + x, z: acc.z + z }), { x: 0, z: 0 })
(acc, [x, z]) => ({ x: acc.x + x, z: acc.z + z }),
{ x: 0, z: 0 },
)
centroid.x /= Math.max(polygon.length, 1) centroid.x /= Math.max(polygon.length, 1)
centroid.z /= Math.max(polygon.length, 1) centroid.z /= Math.max(polygon.length, 1)
@@ -70,7 +67,10 @@ function dedupePolygonPoints(
return deduped return deduped
} }
function simplifyPolyline(points: Array<[number, number]>, tolerance: number): Array<[number, number]> { function simplifyPolyline(
points: Array<[number, number]>,
tolerance: number,
): Array<[number, number]> {
if (points.length <= 2) { if (points.length <= 2) {
return points.map(([x, z]) => [x, z] as [number, number]) return points.map(([x, z]) => [x, z] as [number, number])
} }
+27 -16
View File
@@ -1,14 +1,20 @@
import { import {
getClampedWallCurveOffset, CeilingNode,
getWallCurveFrameAt, type CeilingNode as CeilingNodeType,
isCurvedWall, SlabNode,
} from '../systems/wall/wall-curve' type SlabNode as SlabNodeType,
import { CeilingNode, SlabNode, type CeilingNode as CeilingNodeType, type SlabNode as SlabNodeType, type WallNode } from '../schema' type WallNode,
} from '../schema'
import { import {
getSceneHistoryPauseDepth, getSceneHistoryPauseDepth,
pauseSceneHistory, pauseSceneHistory,
resumeSceneHistory, resumeSceneHistory,
} from '../store/history-control' } from '../store/history-control'
import {
getClampedWallCurveOffset,
getWallCurveFrameAt,
isCurvedWall,
} from '../systems/wall/wall-curve'
import { simplifyClosedPolygon } from './polygon-geometry' import { simplifyClosedPolygon } from './polygon-geometry'
type Point2D = { x: number; y: number } type Point2D = { x: number; y: number }
@@ -147,10 +153,10 @@ function polygonCentroid(points: Point2D[]) {
} }
function bboxOf(points: Point2D[]) { function bboxOf(points: Point2D[]) {
let minX = Infinity let minX = Number.POSITIVE_INFINITY
let minY = Infinity let minY = Number.POSITIVE_INFINITY
let maxX = -Infinity let maxX = Number.NEGATIVE_INFINITY
let maxY = -Infinity let maxY = Number.NEGATIVE_INFINITY
for (const point of points) { for (const point of points) {
minX = Math.min(minX, point.x) minX = Math.min(minX, point.x)
@@ -216,7 +222,13 @@ function sampleWallPointsForRoomDetection(
return [start, end] return [start, end]
} }
const subdivide = (t0: number, p0: Point2D, t1: number, p1: Point2D, depth: number): Point2D[] => { const subdivide = (
t0: number,
p0: Point2D,
t1: number,
p1: Point2D,
depth: number,
): Point2D[] => {
const midT = (t0 + t1) / 2 const midT = (t0 + t1) / 2
const midPoint = getWallCurveFrameAt(wall, midT).point const midPoint = getWallCurveFrameAt(wall, midT).point
const deviation = pointLineDistance(midPoint, p0, p1) const deviation = pointLineDistance(midPoint, p0, p1)
@@ -361,7 +373,7 @@ function extractRoomPolygons(walls: WallNode[]): Point2D[][] {
const signedArea = polygonArea(polygon) const signedArea = polygonArea(polygon)
if (signedArea <= 0) continue if (signedArea <= 0) continue
if (signedArea < 0.5 || signedArea > 10000) continue if (signedArea < 0.5 || signedArea > 10_000) continue
const signature = polygonSignature(polygon) const signature = polygonSignature(polygon)
if (faces.some((face) => polygonSignature(face) === signature)) continue if (faces.some((face) => polygonSignature(face) === signature)) continue
@@ -442,10 +454,7 @@ function nextAutoRoomName(
return `Room ${maxIndex + 1} ${suffix}` return `Room ${maxIndex + 1} ${suffix}`
} }
function sameTuplePolygon( function sameTuplePolygon(current: Array<[number, number]>, next: Array<[number, number]>) {
current: Array<[number, number]>,
next: Array<[number, number]>,
) {
return ( return (
current.length === next.length && current.length === next.length &&
current.every((point, index) => point[0] === next[index]?.[0] && point[1] === next[index]?.[1]) current.every((point, index) => point[0] === next[index]?.[0] && point[1] === next[index]?.[1])
@@ -727,7 +736,9 @@ function syncAutoCeilingsForLevel(
const polygon = updatesById.get(ceiling.id) const polygon = updatesById.get(ceiling.id)
if (!polygon) return [] if (!polygon) return []
return sameTuplePolygon(ceiling.polygon, polygon) ? [] : [{ id: ceiling.id, data: { polygon } }] return sameTuplePolygon(ceiling.polygon, polygon)
? []
: [{ id: ceiling.id, data: { polygon } }]
}) })
const plannedCeilingsForNaming: Array<{ name?: string }> = [...existingCeilings] const plannedCeilingsForNaming: Array<{ name?: string }> = [...existingCeilings]
+28
View File
@@ -1,5 +1,7 @@
import { import {
type MaterialPresetPayload, type MaterialPresetPayload,
type MaterialTarget,
MaterialTarget as MaterialTargetSchema,
} from './schema/material' } from './schema/material'
export type MaterialCatalogItem = { export type MaterialCatalogItem = {
@@ -12,6 +14,32 @@ export type MaterialCatalogItem = {
preset: MaterialPresetPayload preset: MaterialPresetPayload
} }
const WALL_TARGETS: MaterialTarget[] = [MaterialTargetSchema.enum.wall]
const SLAB_TARGETS: MaterialTarget[] = [MaterialTargetSchema.enum.slab]
const WALL_AND_SLAB_TARGETS: MaterialTarget[] = [
MaterialTargetSchema.enum.wall,
MaterialTargetSchema.enum.slab,
]
const STAIR_TARGETS: MaterialTarget[] = [
MaterialTargetSchema.enum.stair,
MaterialTargetSchema.enum['stair-segment'],
]
const STAIR_AND_FENCE_TARGETS: MaterialTarget[] = [
...STAIR_TARGETS,
MaterialTargetSchema.enum.fence,
]
const ROOF_TARGETS: MaterialTarget[] = [
MaterialTargetSchema.enum.roof,
MaterialTargetSchema.enum['roof-segment'],
]
const CEILING_TARGETS: MaterialTarget[] = [MaterialTargetSchema.enum.ceiling]
export const MATERIAL_CATEGORIES = [ export const MATERIAL_CATEGORIES = [
'wood', 'wood',
'wallpaper', 'wallpaper',
@@ -1,7 +1,3 @@
// @ts-expect-error — bun:test is provided by the Bun runtime; core does not
// depend on @types/bun so the import type is unresolved at compile time.
// The tsconfig in packages/core still emits this file; the @ts-expect-error
// keeps the build green while letting `bun test` pick it up normally.
import { afterEach, beforeEach, describe, expect, test } from 'bun:test' import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
import { ALLOWED_ORIGINS_ENV, AssetUrl } from './asset-url' import { ALLOWED_ORIGINS_ENV, AssetUrl } from './asset-url'
+2 -2
View File
@@ -13,9 +13,9 @@ export const FenceNode = BaseNode.extend({
materialPreset: z.string().optional(), materialPreset: z.string().optional(),
start: z.tuple([z.number(), z.number()]), start: z.tuple([z.number(), z.number()]),
end: z.tuple([z.number(), z.number()]), end: z.tuple([z.number(), z.number()]),
curveOffset: z.number().optional(),
height: z.number().default(1.8), height: z.number().default(1.8),
thickness: z.number().default(0.08), thickness: z.number().default(0.08),
curveOffset: z.number().optional(),
baseHeight: z.number().default(0.22), baseHeight: z.number().default(0.22),
postSpacing: z.number().default(2), postSpacing: z.number().default(2),
postSize: z.number().default(0.1), postSize: z.number().default(0.1),
@@ -30,8 +30,8 @@ export const FenceNode = BaseNode.extend({
dedent` dedent`
Fence node - used to represent a fence segment in the building/site level coordinate system Fence node - used to represent a fence segment in the building/site level coordinate system
- start/end: fence endpoints in level coordinate system - start/end: fence endpoints in level coordinate system
- curveOffset: midpoint sagitta offset used to bend the fence into an arc
- height/thickness: overall fence dimensions in meters - height/thickness: overall fence dimensions in meters
- curveOffset: midpoint sagitta offset used to bend the fence into an arc
- baseHeight/postSpacing/postSize/topRailHeight: exact geometric controls from the plan3D fence model - baseHeight/postSpacing/postSize/topRailHeight: exact geometric controls from the plan3D fence model
- groundClearance/edgeInset/baseStyle: fence support and inset configuration - groundClearance/edgeInset/baseStyle: fence support and inset configuration
- showInfill: whether to draw intermediate posts/slats between end posts - showInfill: whether to draw intermediate posts/slats between end posts
+12
View File
@@ -83,6 +83,18 @@ const assetSchema = z.object({
// Optional top-down 2D image shown inside the item's footprint on the // Optional top-down 2D image shown inside the item's footprint on the
// floor plan. When present, replaces the default diagonal-cross marker. // floor plan. When present, replaces the default diagonal-cross marker.
floorPlanUrl: z.string().optional(), floorPlanUrl: z.string().optional(),
// Where the item came from in the catalog. Used by the editor's items
// panel to filter Library / Community / Mine. The server populates it
// from `items.userId`: null → 'library', current user → 'mine',
// other user → 'community'. Defaults to 'library' when absent (e.g.
// the seeded built-in catalog).
source: z.enum(['library', 'community', 'mine']).default('library'),
// True when the item belongs to the caller and is still in draft status.
// The catalog only loads my drafts (other users' drafts are never
// published to the catalog). Used so the Community filter can include
// *my* published items alongside other users', while leaving drafts
// visible only under Mine.
isDraft: z.boolean().optional(),
src: AssetUrl, src: AssetUrl,
dimensions: z.tuple([z.number(), z.number(), z.number()]).default([1, 1, 1]), // [w, h, d] dimensions: z.tuple([z.number(), z.number(), z.number()]).default([1, 1, 1]), // [w, h, d]
attachTo: z.enum(['wall', 'wall-side', 'ceiling']).optional(), attachTo: z.enum(['wall', 'wall-side', 'ceiling']).optional(),
+9 -7
View File
@@ -1,25 +1,26 @@
import dedent from 'dedent' import dedent from 'dedent'
import { z } from 'zod' import { z } from 'zod'
import { BaseNode, nodeType, objectId } from '../base' import { BaseNode, nodeType, objectId } from '../base'
import { type MaterialSchema, MaterialSchema as MaterialSchemaSchema } from '../material' import type { MaterialSchema as MaterialSchemaType } from '../material'
import { MaterialSchema } from '../material'
import { RoofSegmentNode } from './roof-segment' import { RoofSegmentNode } from './roof-segment'
export type RoofSurfaceMaterialRole = 'top' | 'edge' | 'wall' export type RoofSurfaceMaterialRole = 'top' | 'edge' | 'wall'
export type RoofSurfaceMaterialSpec = { export type RoofSurfaceMaterialSpec = {
material?: MaterialSchema material?: MaterialSchemaType
materialPreset?: string materialPreset?: string
} }
export const RoofNode = BaseNode.extend({ export const RoofNode = BaseNode.extend({
id: objectId('roof'), id: objectId('roof'),
type: nodeType('roof'), type: nodeType('roof'),
material: MaterialSchemaSchema.optional(), material: MaterialSchema.optional(),
materialPreset: z.string().optional(), materialPreset: z.string().optional(),
topMaterial: MaterialSchemaSchema.optional(), topMaterial: MaterialSchema.optional(),
topMaterialPreset: z.string().optional(), topMaterialPreset: z.string().optional(),
edgeMaterial: MaterialSchemaSchema.optional(), edgeMaterial: MaterialSchema.optional(),
edgeMaterialPreset: z.string().optional(), edgeMaterialPreset: z.string().optional(),
wallMaterial: MaterialSchemaSchema.optional(), wallMaterial: MaterialSchema.optional(),
wallMaterialPreset: z.string().optional(), wallMaterialPreset: z.string().optional(),
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
// Rotation around Y axis in radians // Rotation around Y axis in radians
@@ -54,7 +55,8 @@ export function getEffectiveRoofSurfaceMaterial(
if (node.topMaterial !== undefined || typeof node.topMaterialPreset === 'string') { if (node.topMaterial !== undefined || typeof node.topMaterialPreset === 'string') {
return { return {
material: node.topMaterial, material: node.topMaterial,
materialPreset: typeof node.topMaterialPreset === 'string' ? node.topMaterialPreset : undefined, materialPreset:
typeof node.topMaterialPreset === 'string' ? node.topMaterialPreset : undefined,
} }
} }
} }
+19 -10
View File
@@ -1,7 +1,8 @@
import dedent from 'dedent' import dedent from 'dedent'
import { z } from 'zod' import { z } from 'zod'
import { BaseNode, nodeType, objectId } from '../base' import { BaseNode, nodeType, objectId } from '../base'
import { type MaterialSchema, MaterialSchema as MaterialSchemaSchema } from '../material' import type { MaterialSchema as MaterialSchemaType } from '../material'
import { MaterialSchema } from '../material'
import { StairSegmentNode } from './stair-segment' import { StairSegmentNode } from './stair-segment'
export const StairRailingMode = z.enum(['none', 'left', 'right', 'both']) export const StairRailingMode = z.enum(['none', 'left', 'right', 'both'])
@@ -15,20 +16,20 @@ export type StairTopLandingMode = z.infer<typeof StairTopLandingMode>
export type StairSlabOpeningMode = z.infer<typeof StairSlabOpeningMode> export type StairSlabOpeningMode = z.infer<typeof StairSlabOpeningMode>
export type StairSurfaceMaterialRole = 'railing' | 'tread' | 'side' export type StairSurfaceMaterialRole = 'railing' | 'tread' | 'side'
export type StairSurfaceMaterialSpec = { export type StairSurfaceMaterialSpec = {
material?: MaterialSchema material?: MaterialSchemaType
materialPreset?: string materialPreset?: string
} }
export const StairNode = BaseNode.extend({ export const StairNode = BaseNode.extend({
id: objectId('stair'), id: objectId('stair'),
type: nodeType('stair'), type: nodeType('stair'),
material: MaterialSchemaSchema.optional(), material: MaterialSchema.optional(),
materialPreset: z.string().optional(), materialPreset: z.string().optional(),
railingMaterial: MaterialSchemaSchema.optional(), railingMaterial: MaterialSchema.optional(),
railingMaterialPreset: z.string().optional(), railingMaterialPreset: z.string().optional(),
treadMaterial: MaterialSchemaSchema.optional(), treadMaterial: MaterialSchema.optional(),
treadMaterialPreset: z.string().optional(), treadMaterialPreset: z.string().optional(),
sideMaterial: MaterialSchemaSchema.optional(), sideMaterial: MaterialSchema.optional(),
sideMaterialPreset: z.string().optional(), sideMaterialPreset: z.string().optional(),
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
// Rotation around Y axis in radians // Rotation around Y axis in radians
@@ -126,18 +127,26 @@ export function getEffectiveStairSurfaceMaterial(
const treadFallback = { const treadFallback = {
material: node.treadMaterial, material: node.treadMaterial,
materialPreset: typeof node.treadMaterialPreset === 'string' ? node.treadMaterialPreset : undefined, materialPreset:
typeof node.treadMaterialPreset === 'string' ? node.treadMaterialPreset : undefined,
} }
const sideFallback = { const sideFallback = {
material: node.sideMaterial, material: node.sideMaterial,
materialPreset: typeof node.sideMaterialPreset === 'string' ? node.sideMaterialPreset : undefined, materialPreset:
typeof node.sideMaterialPreset === 'string' ? node.sideMaterialPreset : undefined,
} }
if (role === 'tread' && (sideFallback.material !== undefined || sideFallback.materialPreset !== undefined)) { if (
role === 'tread' &&
(sideFallback.material !== undefined || sideFallback.materialPreset !== undefined)
) {
return sideFallback return sideFallback
} }
if (role === 'side' && (treadFallback.material !== undefined || treadFallback.materialPreset !== undefined)) { if (
role === 'side' &&
(treadFallback.material !== undefined || treadFallback.materialPreset !== undefined)
) {
return treadFallback return treadFallback
} }
@@ -125,7 +125,7 @@ function buildMergedWallAttachmentUpdates(
const wallChildren = [...(primary.children ?? []), ...(secondary.children ?? [])] as AnyNodeId[] const wallChildren = [...(primary.children ?? []), ...(secondary.children ?? [])] as AnyNodeId[]
for (const childId of wallChildren) { for (const childId of wallChildren) {
const child = nodes[childId] const child = nodes[childId]
if (!child || !('position' in child) || !Array.isArray(child.position)) { if (!(child && 'position' in child && Array.isArray(child.position))) {
continue continue
} }
@@ -193,10 +193,12 @@ function buildWallMergePlans(
}) })
const [primary, secondary] = sortedCandidates const [primary, secondary] = sortedCandidates
if ( if (
!primary || !(
!secondary || primary &&
!areWallStylesCompatible(primary, secondary) || secondary &&
!areWallsCollinearAcrossPoint(primary, secondary, junction) areWallStylesCompatible(primary, secondary) &&
areWallsCollinearAcrossPoint(primary, secondary, junction)
)
) { ) {
continue continue
} }
@@ -278,6 +280,7 @@ export const createNodesAction = (
ops.forEach(({ node, parentId }) => { ops.forEach(({ node, parentId }) => {
get().markDirty(node.id) get().markDirty(node.id)
if (parentId) get().markDirty(parentId) if (parentId) get().markDirty(parentId)
else if (node.parentId) get().markDirty(node.parentId as AnyNodeId)
}) })
} }
+12 -5
View File
@@ -111,7 +111,7 @@ function migrateWallSurfaceMaterials(node: Record<string, any>) {
materialPreset: typeof node.materialPreset === 'string' ? node.materialPreset : undefined, materialPreset: typeof node.materialPreset === 'string' ? node.materialPreset : undefined,
} }
if (!hasInterior && !hasExterior) { if (!(hasInterior || hasExterior)) {
if (legacyFinish.material === undefined && legacyFinish.materialPreset === undefined) { if (legacyFinish.material === undefined && legacyFinish.materialPreset === undefined) {
return node return node
} }
@@ -174,7 +174,7 @@ function migrateStairSurfaceMaterials(node: Record<string, any>) {
return legacyFinish return legacyFinish
} }
if (!hasRailing && !hasTread && !hasSide) { if (!(hasRailing || hasTread || hasSide)) {
if (legacyFinish.material === undefined && legacyFinish.materialPreset === undefined) { if (legacyFinish.material === undefined && legacyFinish.materialPreset === undefined) {
return node return node
} }
@@ -236,7 +236,7 @@ function migrateRoofSurfaceMaterials(node: Record<string, any>) {
materialPreset: typeof node.materialPreset === 'string' ? node.materialPreset : undefined, materialPreset: typeof node.materialPreset === 'string' ? node.materialPreset : undefined,
} }
if (!hasTop && !hasEdge && !hasWall) { if (!(hasTop || hasEdge || hasWall)) {
if (legacyFinish.material === undefined && legacyFinish.materialPreset === undefined) { if (legacyFinish.material === undefined && legacyFinish.materialPreset === undefined) {
return node return node
} }
@@ -350,7 +350,7 @@ function migrateNodes(nodes: Record<string, any>): Record<string, AnyNode> {
} }
function getNodeChildIds(node: AnyNode): AnyNodeId[] { function getNodeChildIds(node: AnyNode): AnyNodeId[] {
if (!('children' in node) || !Array.isArray(node.children)) { if (!('children' in node && Array.isArray(node.children))) {
return [] return []
} }
@@ -516,6 +516,13 @@ const useScene: UseSceneStore = create<SceneState>()(
} }
} }
set({
nodes: cleanedNodes,
rootNodeIds,
dirtyNodes: new Set<AnyNodeId>(),
collections: {},
})
const normalizedRootNodeIds = normalizeRootNodeIds(cleanedNodes, rootNodeIds) const normalizedRootNodeIds = normalizeRootNodeIds(cleanedNodes, rootNodeIds)
const reachableNodeIds = collectReachableNodeIds(cleanedNodes, normalizedRootNodeIds) const reachableNodeIds = collectReachableNodeIds(cleanedNodes, normalizedRootNodeIds)
if (normalizedRootNodeIds.length > 0) { if (normalizedRootNodeIds.length > 0) {
@@ -707,8 +714,8 @@ let prevFutureLength = 0
let prevNodesSnapshot: Record<AnyNodeId, AnyNode> | null = null let prevNodesSnapshot: Record<AnyNodeId, AnyNode> | null = null
export function clearSceneHistory() { export function clearSceneHistory() {
useScene.temporal.getState().clear()
resetSceneHistoryPauseDepth() resetSceneHistoryPauseDepth()
useScene.temporal.getState().clear()
prevPastLength = 0 prevPastLength = 0
prevFutureLength = 0 prevFutureLength = 0
prevNodesSnapshot = null prevNodesSnapshot = null
@@ -1,5 +1,3 @@
// @ts-expect-error — bun:test is provided by the Bun runtime; core does not
// depend on @types/bun so the import type is unresolved at compile time.
import { describe, expect, test } from 'bun:test' import { describe, expect, test } from 'bun:test'
import type { AnyNode } from '../../schema' import type { AnyNode } from '../../schema'
import { BuildingNode, LevelNode, SlabNode, StairNode, StairSegmentNode } from '../../schema' import { BuildingNode, LevelNode, SlabNode, StairNode, StairSegmentNode } from '../../schema'
@@ -1,14 +1,12 @@
import { resolveLevelId } from '../../hooks/spatial-grid/spatial-grid-sync'
import type { import type {
AnyNode, AnyNode,
AnyNodeId, AnyNodeId,
CeilingNode, CeilingNode,
LevelNode,
SlabNode, SlabNode,
StairNode, StairNode,
StairSegmentNode, StairSegmentNode,
} from '../../schema' } from '../../schema'
import { resolveLevelId } from '../../hooks/spatial-grid/spatial-grid-sync'
import { DEFAULT_WALL_HEIGHT } from '../wall/wall-footprint' import { DEFAULT_WALL_HEIGHT } from '../wall/wall-footprint'
type Point2D = [number, number] type Point2D = [number, number]
@@ -36,10 +34,9 @@ type AxisAlignedRect = {
maxZ: number maxZ: number
} }
const CURVED_STAIR_SLAB_OPENING_RATIO = 0.9 const CURVED_STAIR_SLAB_OPENING_RATIO = 0.8
const STRAIGHT_STAIR_TARGET_THRESHOLD_MIN = 0.35 const STRAIGHT_STAIR_TARGET_THRESHOLD_MIN = 0.35
const STAIR_SLAB_OPENING_TIGHTENING = 0 const STAIR_SLAB_OPENING_TIGHTENING = 0
const CURVED_STAIR_OPENING_STEP_PADDING = 3
function clamp(value: number, min: number, max: number) { function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value)) return Math.min(max, Math.max(min, value))
@@ -280,7 +277,7 @@ function polygonArea(points: Point2D[]) {
for (let index = 0; index < points.length; index += 1) { for (let index = 0; index < points.length; index += 1) {
const current = points[index] const current = points[index]
const next = points[(index + 1) % points.length] const next = points[(index + 1) % points.length]
if (!current || !next) continue if (!(current && next)) continue
area += current[0] * next[1] - next[0] * current[1] area += current[0] * next[1] - next[0] * current[1]
} }
return area / 2 return area / 2
@@ -426,39 +423,24 @@ function buildUnionPolygonsFromRects(rects: AxisAlignedRect[]): Point2D[][] {
return polygons return polygons
} }
function getCurvedOpeningStepCount( function getCurvedOpeningPolygon(stair: StairNode): Point2D[] {
stair: StairNode, const width = Math.max(stair.width ?? 1, 0.4)
innerRadius: number, const innerRadius = Math.max(0.2, stair.innerRadius ?? 0.9)
outerRadius: number, const outerRadius = innerRadius + width
totalSweep: number, const totalSweep = stair.sweepAngle ?? Math.PI / 2
) { const openingSweep =
const stepCount = Math.max(2, Math.round(stair.stepCount ?? 10)) Math.sign(totalSweep || 1) *
const stepSweep = Math.abs(totalSweep) / stepCount
const midRadius = Math.max((innerRadius + outerRadius) * 0.5, 0.01)
const treadDepth = Math.max(stepSweep * midRadius, 0.2)
return Math.min(
stepCount,
Math.max( Math.max(
1, Math.abs(totalSweep) * CURVED_STAIR_SLAB_OPENING_RATIO,
Math.ceil(1.8 / treadDepth), Math.abs(totalSweep) / Math.max(stair.stepCount ?? 1, 1),
Math.ceil(stepCount * CURVED_STAIR_SLAB_OPENING_RATIO), )
), const startAngle = totalSweep / 2 - openingSweep
) const endAngle = totalSweep / 2
}
function buildArcOpeningPolygon(
stair: StairNode,
innerRadius: number,
outerRadius: number,
startAngle: number,
endAngle: number,
): Point2D[] {
const sweep = endAngle - startAngle
const segmentCount = Math.max( const segmentCount = Math.max(
10, 10,
Math.min( Math.min(
32, 32,
Math.ceil(Math.abs(sweep) / (Math.PI / 24) + Math.max(stair.stepCount ?? 1, 1) * 0.5), Math.ceil(Math.abs(openingSweep) / (Math.PI / 24) + Math.max(stair.stepCount ?? 1, 1) * 0.5),
), ),
) )
const outerPoints: Point2D[] = [] const outerPoints: Point2D[] = []
@@ -466,7 +448,7 @@ function buildArcOpeningPolygon(
for (let index = 0; index <= segmentCount; index++) { for (let index = 0; index <= segmentCount; index++) {
const t = index / segmentCount const t = index / segmentCount
const angle = startAngle + sweep * t const angle = startAngle + (endAngle - startAngle) * t
outerPoints.push( outerPoints.push(
toWorldPlanPoint(stair, Math.cos(angle) * outerRadius, Math.sin(angle) * outerRadius), toWorldPlanPoint(stair, Math.cos(angle) * outerRadius, Math.sin(angle) * outerRadius),
) )
@@ -474,8 +456,7 @@ function buildArcOpeningPolygon(
for (let index = segmentCount; index >= 0; index--) { for (let index = segmentCount; index >= 0; index--) {
const t = index / segmentCount const t = index / segmentCount
const angle = startAngle + sweep * t const angle = startAngle + (endAngle - startAngle) * t
innerPoints.push( innerPoints.push(
toWorldPlanPoint(stair, Math.cos(angle) * innerRadius, Math.sin(angle) * innerRadius), toWorldPlanPoint(stair, Math.cos(angle) * innerRadius, Math.sin(angle) * innerRadius),
) )
@@ -484,39 +465,6 @@ function buildArcOpeningPolygon(
return [...outerPoints, ...innerPoints] return [...outerPoints, ...innerPoints]
} }
function getCurvedOpeningPolygon(stair: StairNode, targetElevation?: number): 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 totalSweep = stair.sweepAngle ?? Math.PI / 2
const stepCount = Math.max(2, Math.round(stair.stepCount ?? 10))
const stepHeight = Math.max(stair.totalRise ?? 2.5, 0.1) / stepCount
const stepSweep = totalSweep / stepCount
const targetThreshold = Math.max(stepHeight * 2, STRAIGHT_STAIR_TARGET_THRESHOLD_MIN)
const endAngle = totalSweep / 2
const fallbackStartStepIndex = Math.max(
0,
stepCount - getCurvedOpeningStepCount(stair, innerRadius, outerRadius, totalSweep),
)
let startStepIndex = fallbackStartStepIndex
if (typeof targetElevation === 'number') {
for (let index = 0; index < stepCount; index += 1) {
const stepTopElevation = stepHeight * (index + 1)
if (stepTopElevation >= targetElevation - targetThreshold) {
startStepIndex = Math.max(
0,
Math.min(fallbackStartStepIndex, index - CURVED_STAIR_OPENING_STEP_PADDING),
)
break
}
}
}
const startAngle = -totalSweep / 2 + stepSweep * startStepIndex
return buildArcOpeningPolygon(stair, innerRadius, outerRadius, startAngle, endAngle)
}
function getSpiralOpeningPolygon(stair: StairNode): Point2D[] { function getSpiralOpeningPolygon(stair: StairNode): Point2D[] {
const radius = Math.max(0.05, stair.innerRadius ?? 0.9) + Math.max(stair.width ?? 1, 0.4) const radius = Math.max(0.05, stair.innerRadius ?? 0.9) + Math.max(stair.width ?? 1, 0.4)
const segmentCount = 48 const segmentCount = 48
@@ -621,7 +569,7 @@ function getStairOpeningPolygons(
} }
if (stair.stairType === 'curved') { if (stair.stairType === 'curved') {
return [getCurvedOpeningPolygon(stair, targetElevation)] return [getCurvedOpeningPolygon(stair)]
} }
if (stair.stairType === 'spiral') { if (stair.stairType === 'spiral') {
@@ -775,8 +723,7 @@ export function syncAutoStairOpenings(nodes: Record<string, AnyNode>) {
const nextMetadata = [...manualMetadata, ...stairHoles.map((hole) => hole.metadata)] const nextMetadata = [...manualMetadata, ...stairHoles.map((hole) => hole.metadata)]
if ( if (
!polygonsEqual(existingHoles, nextHoles) || !(polygonsEqual(existingHoles, nextHoles) && metadataEqual(existingMetadata, nextMetadata))
!metadataEqual(existingMetadata, nextMetadata)
) { ) {
updates.push({ updates.push({
id: slab.id, id: slab.id,
@@ -826,8 +773,7 @@ export function syncAutoStairOpenings(nodes: Record<string, AnyNode>) {
const nextMetadata = [...manualMetadata, ...stairHoles.map((hole) => hole.metadata)] const nextMetadata = [...manualMetadata, ...stairHoles.map((hole) => hole.metadata)]
if ( if (
!polygonsEqual(existingHoles, nextHoles) || !(polygonsEqual(existingHoles, nextHoles) && metadataEqual(existingMetadata, nextMetadata))
!metadataEqual(existingMetadata, nextMetadata)
) { ) {
updates.push({ updates.push({
id: ceiling.id, id: ceiling.id,
+6 -3
View File
@@ -1,5 +1,5 @@
import type { Point2D } from './wall-mitering'
import type { FenceNode, WallNode } from '../../schema' import type { FenceNode, WallNode } from '../../schema'
import type { Point2D } from './wall-mitering'
const CURVE_EPSILON = 1e-6 const CURVE_EPSILON = 1e-6
const DEFAULT_SAMPLE_SEGMENTS = 24 const DEFAULT_SAMPLE_SEGMENTS = 24
@@ -115,7 +115,7 @@ function getWallArcData(wall: WallCurveLike) {
} }
const absSagitta = Math.abs(sagitta) const absSagitta = Math.abs(sagitta)
const radius = chord.length * chord.length / (8 * absSagitta) + absSagitta / 2 const radius = (chord.length * chord.length) / (8 * absSagitta) + absSagitta / 2
const centerOffset = radius - absSagitta const centerOffset = radius - absSagitta
const direction = Math.sign(sagitta) || 1 const direction = Math.sign(sagitta) || 1
const center = { const center = {
@@ -183,7 +183,10 @@ export function getWallMidpointHandlePoint(wall: WallCurveLike) {
export function sampleWallCenterline(wall: WallCurveLike, segments = DEFAULT_SAMPLE_SEGMENTS) { export function sampleWallCenterline(wall: WallCurveLike, segments = DEFAULT_SAMPLE_SEGMENTS) {
const count = Math.max(1, segments) const count = Math.max(1, segments)
return Array.from({ length: count + 1 }, (_, index) => getWallCurveFrameAt(wall, index / count).point) return Array.from(
{ length: count + 1 },
(_, index) => getWallCurveFrameAt(wall, index / count).point,
)
} }
export function getWallCurveLength(wall: WallCurveLike, segments = DEFAULT_SAMPLE_SEGMENTS) { export function getWallCurveLength(wall: WallCurveLike, segments = DEFAULT_SAMPLE_SEGMENTS) {
@@ -135,10 +135,7 @@ function findJunctions(walls: WallNode[]): Map<string, Junction> {
return actualJunctions return actualJunctions
} }
function getWallDirectionFromJunction( function getWallDirectionFromJunction(wall: WallNode, endType: 'start' | 'end' | 'passthrough') {
wall: WallNode,
endType: 'start' | 'end' | 'passthrough',
) {
if (endType === 'passthrough') { if (endType === 'passthrough') {
return { return {
x: wall.end[0] - wall.start[0], x: wall.end[0] - wall.start[0],
@@ -148,9 +145,7 @@ function getWallDirectionFromJunction(
if (isCurvedWall(wall)) { if (isCurvedWall(wall)) {
const frame = getWallCurveFrameAt(wall, endType === 'start' ? 0 : 1) const frame = getWallCurveFrameAt(wall, endType === 'start' ? 0 : 1)
return endType === 'start' return endType === 'start' ? frame.tangent : { x: -frame.tangent.x, y: -frame.tangent.y }
? frame.tangent
: { x: -frame.tangent.x, y: -frame.tangent.y }
} }
return endType === 'start' return endType === 'start'
@@ -158,18 +153,12 @@ function getWallDirectionFromJunction(
: { x: wall.start[0] - wall.end[0], y: wall.start[1] - wall.end[1] } : { x: wall.start[0] - wall.end[0], y: wall.start[1] - wall.end[1] }
} }
function getWallBoundaryFrame( function getWallBoundaryFrame(wall: WallNode, endType: 'start' | 'end') {
wall: WallNode,
endType: 'start' | 'end',
) {
if (isCurvedWall(wall)) { if (isCurvedWall(wall)) {
const frame = getWallCurveFrameAt(wall, endType === 'start' ? 0 : 1) const frame = getWallCurveFrameAt(wall, endType === 'start' ? 0 : 1)
return { return {
point: frame.point, point: frame.point,
tangent: tangent: endType === 'start' ? frame.tangent : { x: -frame.tangent.x, y: -frame.tangent.y },
endType === 'start'
? frame.tangent
: { x: -frame.tangent.x, y: -frame.tangent.y },
normal: frame.normal, normal: frame.normal,
} }
} }
+2 -1
View File
@@ -5,7 +5,8 @@
"rootDir": "src", "rootDir": "src",
"noEmit": false, "noEmit": false,
"composite": true, "composite": true,
"incremental": true "incremental": true,
"types": ["bun"]
}, },
"include": ["src"], "include": ["src"],
"exclude": ["node_modules", "dist"] "exclude": ["node_modules", "dist"]
+10 -10
View File
@@ -1,6 +1,6 @@
{ {
"name": "@pascal-app/editor", "name": "@pascal-app/editor",
"version": "0.6.0", "version": "0.8.0",
"description": "Pascal building editor component", "description": "Pascal building editor component",
"type": "module", "type": "module",
"exports": { "exports": {
@@ -11,8 +11,8 @@
"check-types": "tsc --noEmit" "check-types": "tsc --noEmit"
}, },
"peerDependencies": { "peerDependencies": {
"@pascal-app/core": "^0.6.0", "@pascal-app/core": "^0.8.0",
"@pascal-app/viewer": "^0.6.0", "@pascal-app/viewer": "^0.8.0",
"@react-three/drei": "^10", "@react-three/drei": "^10",
"@react-three/fiber": "^9", "@react-three/fiber": "^9",
"next": ">=15", "next": ">=15",
@@ -44,24 +44,24 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"howler": "^2.2.4", "howler": "^2.2.4",
"lucide-react": "^0.562.0", "lucide-react": "^1.7.0",
"mitt": "^3.0.1", "mitt": "^3.0.1",
"motion": "^12.34.3", "motion": "^12.34.3",
"nanoid": "^5.1.6", "nanoid": "^5.1.6",
"tailwind-merge": "^3.5.0", "tailwind-merge": "^3.5.0",
"three-mesh-bvh": "^0.9.8",
"zod": "^4.3.6", "zod": "^4.3.6",
"zustand": "^5.0.11" "zustand": "^5.0.11",
"three-mesh-bvh": "~0.9.8"
}, },
"devDependencies": { "devDependencies": {
"@pascal-app/core": "^0.6.0", "@pascal-app/core": "^0.8.0",
"@pascal-app/viewer": "^0.6.0", "@pascal-app/viewer": "^0.8.0",
"@pascal/typescript-config": "*", "@pascal/typescript-config": "*",
"@types/bun": "^1.3.0",
"@types/howler": "^2.2.12", "@types/howler": "^2.2.12",
"@types/node": "^22.19.12",
"@types/react": "19.2.2", "@types/react": "19.2.2",
"@types/react-dom": "19.2.2", "@types/react-dom": "19.2.2",
"@types/three": "^0.184.0", "@types/three": "^0.184.0",
"typescript": "5.9.3" "typescript": "6.0.2"
} }
} }
@@ -72,7 +72,12 @@ export const FloorplanDraftLayer = memo(function FloorplanDraftLayer({
)} )}
{polygonDraftPolygonPoints && ( {polygonDraftPolygonPoints && (
<polygon fill={draftFill} fillOpacity={0.2} points={polygonDraftPolygonPoints} stroke="none" /> <polygon
fill={draftFill}
fillOpacity={0.2}
points={polygonDraftPolygonPoints}
stroke="none"
/>
)} )}
{polygonDraftPolylinePoints && ( {polygonDraftPolylinePoints && (
@@ -125,7 +125,12 @@ export const FloorplanMeasurementsLayer = memo(function FloorplanMeasurementsLay
return ( return (
<> <>
{measurements.map((measurement) => ( {measurements.map((measurement) => (
<g className={className} key={measurement.id} pointerEvents="none" style={{ userSelect: 'none' }}> <g
className={className}
key={measurement.id}
pointerEvents="none"
style={{ userSelect: 'none' }}
>
<FloorplanMeasurementLine <FloorplanMeasurementLine
dashed={measurement.dashedExtensions ?? true} dashed={measurement.dashedExtensions ?? true}
isSelected={measurement.isSelected} isSelected={measurement.isSelected}
@@ -396,8 +396,8 @@ export const FloorplanStairLayer = memo(function FloorplanStairLayer({
<> <>
<polyline <polyline
fill="none" fill="none"
points={formatSvgPolygonPoints(arrow.polyline)}
pointerEvents="none" pointerEvents="none"
points={formatSvgPolygonPoints(arrow.polyline)}
stroke={straightAccent} stroke={straightAccent}
strokeWidth="1.15" strokeWidth="1.15"
vectorEffect="non-scaling-stroke" vectorEffect="non-scaling-stroke"
@@ -411,8 +411,8 @@ export const FloorplanStairLayer = memo(function FloorplanStairLayer({
/> />
<polygon <polygon
fill={straightAccent} fill={straightAccent}
points={formatSvgPolygonPoints(arrow.head)}
pointerEvents="none" pointerEvents="none"
points={formatSvgPolygonPoints(arrow.head)}
/> />
</> </>
) : null} ) : null}
@@ -438,8 +438,6 @@ export const FloorplanStairLayer = memo(function FloorplanStairLayer({
} }
: undefined : undefined
} }
onPointerEnter={canSelectStairs ? () => onStairHoverEnter(stair.id) : undefined}
onPointerLeave={canSelectStairs ? () => onStairHoverChange(null) : undefined}
onPointerDown={ onPointerDown={
canFocusStairs && stairSelected canFocusStairs && stairSelected
? (event) => { ? (event) => {
@@ -449,6 +447,8 @@ export const FloorplanStairLayer = memo(function FloorplanStairLayer({
} }
: undefined : undefined
} }
onPointerEnter={canSelectStairs ? () => onStairHoverEnter(stair.id) : undefined}
onPointerLeave={canSelectStairs ? () => onStairHoverChange(null) : undefined}
pointerEvents={canSelectStairs ? undefined : 'none'} pointerEvents={canSelectStairs ? undefined : 'none'}
style={canSelectStairs ? { cursor } : undefined} style={canSelectStairs ? { cursor } : undefined}
> >
@@ -456,8 +456,8 @@ export const FloorplanStairLayer = memo(function FloorplanStairLayer({
<polygon <polygon
fill="transparent" fill="transparent"
key={`${stair.id}:hit:${polygonIndex}`} key={`${stair.id}:hit:${polygonIndex}`}
points={formatSvgPolygonPoints(polygon)}
pointerEvents={canSelectStairs ? 'all' : 'none'} pointerEvents={canSelectStairs ? 'all' : 'none'}
points={formatSvgPolygonPoints(polygon)}
stroke="transparent" stroke="transparent"
strokeLinejoin="round" strokeLinejoin="round"
strokeWidth={hitStrokeWidth} strokeWidth={hitStrokeWidth}
@@ -1,4 +1,5 @@
'use client' 'use client'
import { import {
type CameraControlEvent, type CameraControlEvent,
type CameraControlFitSceneEvent, type CameraControlFitSceneEvent,
@@ -6,7 +7,7 @@ import {
sceneRegistry, sceneRegistry,
useScene, useScene,
} from '@pascal-app/core' } from '@pascal-app/core'
import { useViewer, ZONE_LAYER } from '@pascal-app/viewer' import { useViewer, WalkthroughControls, ZONE_LAYER } from '@pascal-app/viewer'
import { CameraControls, CameraControlsImpl } from '@react-three/drei' import { CameraControls, CameraControlsImpl } from '@react-three/drei'
import { useThree } from '@react-three/fiber' import { useThree } from '@react-three/fiber'
import { useCallback, useEffect, useMemo, useRef } from 'react' import { useCallback, useEffect, useMemo, useRef } from 'react'
+4 -3
View File
@@ -205,6 +205,7 @@ export function EditorLayoutV2({
viewerContent, viewerContent,
overlays, overlays,
}: EditorLayoutV2Props) { }: EditorLayoutV2Props) {
const isCaptureMode = useEditor((s) => s.isCaptureMode)
const isMobile = useIsMobile() const isMobile = useIsMobile()
if (isMobile) { if (isMobile) {
@@ -229,7 +230,7 @@ export function EditorLayoutV2({
{/* Main content: left column + right column */} {/* Main content: left column + right column */}
<div className="flex min-h-0 flex-1"> <div className="flex min-h-0 flex-1">
{sidebarTabs.length > 0 && ( {!isCaptureMode && sidebarTabs.length > 0 && (
<LeftColumn <LeftColumn
renderTabContent={renderTabContent} renderTabContent={renderTabContent}
sidebarOverlay={sidebarOverlay} sidebarOverlay={sidebarOverlay}
@@ -238,8 +239,8 @@ export function EditorLayoutV2({
)} )}
<RightColumn <RightColumn
overlays={overlays} overlays={overlays}
toolbarLeft={viewerToolbarLeft} toolbarLeft={isCaptureMode ? undefined : viewerToolbarLeft}
toolbarRight={viewerToolbarRight} toolbarRight={isCaptureMode ? undefined : viewerToolbarRight}
> >
{viewerContent} {viewerContent}
</RightColumn> </RightColumn>
@@ -455,14 +455,15 @@ export const FirstPersonControls = () => {
{controllerStart && ( {controllerStart && (
<KeyboardControls map={keyboardMap}> <KeyboardControls map={keyboardMap}>
<BVHEcctrl <BVHEcctrl
ref={controllerRef} acceleration={26}
key="first-person-controller" airDragFactor={0.3}
colliderCapsuleArgs={[0.25, 0.8, 4, 8]} colliderCapsuleArgs={[0.25, 0.8, 4, 8]}
colliderMeshes={[world.mesh]} colliderMeshes={[world.mesh]}
collisionCheckIteration={3} collisionCheckIteration={3}
collisionPushBackDamping={0.1} collisionPushBackDamping={0.1}
collisionPushBackThreshold={0.001} collisionPushBackThreshold={0.001}
debug={false} debug={false}
deceleration={30}
delay={0} delay={0}
fallGravityFactor={4} fallGravityFactor={4}
floatCheckType="BOTH" floatCheckType="BOTH"
@@ -473,13 +474,12 @@ export const FirstPersonControls = () => {
floatSpringK={1200} floatSpringK={1200}
gravity={9.81} gravity={9.81}
jumpVel={6} jumpVel={6}
key="first-person-controller"
maxRunSpeed={5.5} maxRunSpeed={5.5}
maxSlope={1.2} maxSlope={1.2}
maxWalkSpeed={4} maxWalkSpeed={4}
position={controllerStart.position} position={controllerStart.position}
acceleration={26} ref={controllerRef}
airDragFactor={0.3}
deceleration={30}
/> />
</KeyboardControls> </KeyboardControls>
)} )}
@@ -551,12 +551,12 @@ export const FirstPersonOverlay = ({ onExit }: { onExit: () => void }) => {
{isLocked && ( {isLocked && (
<div className="pointer-events-none fixed top-1/2 right-6 z-40 -translate-y-1/2"> <div className="pointer-events-none fixed top-1/2 right-6 z-40 -translate-y-1/2">
<div className="flex min-w-[148px] flex-col gap-3 rounded-2xl border border-border/35 bg-background/80 px-4 py-4 shadow-lg backdrop-blur-xl"> <div className="flex min-w-[148px] flex-col gap-3 rounded-2xl border border-border/35 bg-background/80 px-4 py-4 shadow-lg backdrop-blur-xl">
<ControlHint label="Move" keys={['W', 'A', 'S', 'D']} /> <ControlHint keys={['W', 'A', 'S', 'D']} label="Move" />
<div className="h-px w-full bg-border/30" /> <div className="h-px w-full bg-border/30" />
<InlineControlHint label="Jump" keyLabel="Space" /> <InlineControlHint keyLabel="Space" label="Jump" />
<InlineControlHint label="Sprint" keyLabel="Shift" /> <InlineControlHint keyLabel="Shift" label="Sprint" />
<InlineControlHint label="Interact" keyLabel="E / R" /> <InlineControlHint keyLabel="E / R" label="Interact" />
<InlineControlHint label="Close" keyLabel="T" /> <InlineControlHint keyLabel="T" label="Close" />
<div className="h-px w-full bg-border/30" /> <div className="h-px w-full bg-border/30" />
<span className="text-center text-muted-foreground/60 text-xs"> <span className="text-center text-muted-foreground/60 text-xs">
Click to look around Click to look around
@@ -591,7 +591,7 @@ function ControlHint({ label, keys }: { label: string; keys: string[] }) {
function InlineControlHint({ label, keyLabel }: { label: string; keyLabel: string }) { function InlineControlHint({ label, keyLabel }: { label: string; keyLabel: string }) {
return ( return (
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<span className="font-medium text-[10px] text-muted-foreground/60 tracking-[0.03em] uppercase"> <span className="font-medium text-[10px] text-muted-foreground/60 uppercase tracking-[0.03em]">
{label} {label}
</span> </span>
<kbd className="flex h-5 min-w-5 items-center justify-center rounded border border-border/50 bg-accent/40 px-1.5 font-mono text-[10px] text-foreground/80 leading-none"> <kbd className="flex h-5 min-w-5 items-center justify-center rounded border border-border/50 bg-accent/40 px-1.5 font-mono text-[10px] text-foreground/80 leading-none">
@@ -1,7 +1,7 @@
import { import {
getGarageVisibleOpeningRatio,
type AnyNodeId, type AnyNodeId,
type DoorNode, type DoorNode,
getGarageVisibleOpeningRatio,
isOperationDoorType, isOperationDoorType,
sceneRegistry, sceneRegistry,
useInteractive, useInteractive,
@@ -97,11 +97,11 @@ function shouldSkipColliderNode(nodeId: string, type: (typeof COLLIDER_NODE_TYPE
const node = useScene.getState().nodes[nodeId as AnyNodeId] const node = useScene.getState().nodes[nodeId as AnyNodeId]
if (!node || node.type !== 'door') return false if (!node || node.type !== 'door') return false
if (node.openingKind === 'opening') return true
if (!node.segments.length) return true if (!node.segments.length) return true
return node.segments.every((segment) => segment.type === 'empty') if (node.openingKind === 'opening') return true
return node.segments.every((segment: { type: string }) => segment.type === 'empty')
} }
function createDoorLeafColliderGeometry(root: THREE.Object3D, node: DoorNode) { function createDoorLeafColliderGeometry(root: THREE.Object3D, node: DoorNode) {
@@ -273,9 +273,7 @@ export function buildFirstPersonColliderWorldFromRegistry(): FirstPersonCollider
} }
const mergedGeometry = mergeGeometries(geometries, false) const mergedGeometry = mergeGeometries(geometries, false)
geometries.forEach((geometry) => { geometries.forEach((geometry) => geometry.dispose())
geometry.dispose()
})
if (!mergedGeometry || mergedGeometry.getAttribute('position') == null) { if (!mergedGeometry || mergedGeometry.getAttribute('position') == null) {
mergedGeometry?.dispose() mergedGeometry?.dispose()
@@ -1,15 +1,8 @@
import '../../../three-types' import '../../../three-types'
import { TransformControls, useKeyboardControls } from '@react-three/drei' import { TransformControls, useKeyboardControls } from '@react-three/drei'
import { useFrame, useThree, type ThreeElements } from '@react-three/fiber' import { type ThreeElements, useFrame, useThree } from '@react-three/fiber'
import {
Suspense,
forwardRef,
useCallback,
useImperativeHandle,
useMemo,
useRef,
} from 'react'
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
import { forwardRef, Suspense, useCallback, useImperativeHandle, useMemo, useRef } from 'react'
import * as THREE from 'three' import * as THREE from 'three'
import { clamp } from 'three/src/math/MathUtils.js' import { clamp } from 'three/src/math/MathUtils.js'
@@ -156,16 +149,7 @@ const BVHEcctrl = forwardRef<BVHEcctrlApi, EcctrlProps>(
const moveDirRef = useRef<THREE.ArrowHelper | null>(null) const moveDirRef = useRef<THREE.ArrowHelper | null>(null)
const elapsedRef = useRef(0) const elapsedRef = useRef(0)
function useIsInsideKeyboardControls() { const [, getKeys] = useKeyboardControls()
try {
return !!useKeyboardControls()
} catch {
return false
}
}
const isInsideKeyboardControls = useIsInsideKeyboardControls()
const [_, getKeys] = isInsideKeyboardControls ? useKeyboardControls() : [null, null]
const presetKeys = { const presetKeys = {
forward: false, forward: false,
backward: false, backward: false,
@@ -222,11 +206,11 @@ const BVHEcctrl = forwardRef<BVHEcctrlApi, EcctrlProps>(
const scaledContactRadiusVec = useRef(new THREE.Vector3()) const scaledContactRadiusVec = useRef(new THREE.Vector3())
const deltaDist = useRef(new THREE.Vector3()) const deltaDist = useRef(new THREE.Vector3())
const currSlopeAngle = useRef(0) const currSlopeAngle = useRef(0)
const localMinDistance = useRef(Infinity) const localMinDistance = useRef(Number.POSITIVE_INFINITY)
const localClosestPoint = useRef(new THREE.Vector3()) const localClosestPoint = useRef(new THREE.Vector3())
const localHitNormal = useRef(new THREE.Vector3()) const localHitNormal = useRef(new THREE.Vector3())
const triNormal = useRef(new THREE.Vector3()) const triNormal = useRef(new THREE.Vector3())
const globalMinDistance = useRef(Infinity) const globalMinDistance = useRef(Number.POSITIVE_INFINITY)
const globalClosestPoint = useRef(new THREE.Vector3()) const globalClosestPoint = useRef(new THREE.Vector3())
const triHitPoint = useRef(new THREE.Vector3()) const triHitPoint = useRef(new THREE.Vector3())
const segHitPoint = useRef(new THREE.Vector3()) const segHitPoint = useRef(new THREE.Vector3())
@@ -286,7 +270,13 @@ const BVHEcctrl = forwardRef<BVHEcctrlApi, EcctrlProps>(
const moving = currentLinVel.current.lengthSq() > 1e-6 const moving = currentLinVel.current.lengthSq() > 1e-6
const platformIsMoving = totalPlatformDeltaPos.current.lengthSq() > 1e-6 const platformIsMoving = totalPlatformDeltaPos.current.lengthSq() > 1e-6
if (!moving && isOnGround.current && !jump && !isOnMovingPlatform.current && !platformIsMoving) { if (
!moving &&
isOnGround.current &&
!jump &&
!isOnMovingPlatform.current &&
!platformIsMoving
) {
idleTime.current += delta idleTime.current += delta
if (idleTime.current > sleepTimeout) isSleeping.current = true if (idleTime.current > sleepTimeout) isSleeping.current = true
} else { } else {
@@ -340,7 +330,10 @@ const BVHEcctrl = forwardRef<BVHEcctrlApi, EcctrlProps>(
upAxis.current, upAxis.current,
) )
characterModelTargetQuat.current.setFromRotationMatrix(characterModelLookMatrix.current) characterModelTargetQuat.current.setFromRotationMatrix(characterModelLookMatrix.current)
characterModelRef.current.quaternion.slerp(characterModelTargetQuat.current, delta * turnSpeed) characterModelRef.current.quaternion.slerp(
characterModelTargetQuat.current,
delta * turnSpeed,
)
} }
const maxSpeed = run ? maxRunSpeed : maxWalkSpeed const maxSpeed = run ? maxRunSpeed : maxWalkSpeed
@@ -358,18 +351,33 @@ const BVHEcctrl = forwardRef<BVHEcctrlApi, EcctrlProps>(
) )
currentLinVel.current.add(deltaLinVel.current) currentLinVel.current.add(deltaLinVel.current)
} else if (isOnGround.current) { } else if (isOnGround.current) {
deltaLinVel.current.copy(currentLinVelOnPlane.current).clampLength(0, deceleration * friction * delta) deltaLinVel.current
.copy(currentLinVelOnPlane.current)
.clampLength(0, deceleration * friction * delta)
currentLinVel.current.sub(deltaLinVel.current) currentLinVel.current.sub(deltaLinVel.current)
} }
}, },
[acceleration, airDragFactor, counterAccFactor, deceleration, maxRunSpeed, maxWalkSpeed, turnSpeed, characterOrigin], [
acceleration,
airDragFactor,
counterAccFactor,
deceleration,
maxRunSpeed,
maxWalkSpeed,
turnSpeed,
characterOrigin,
],
) )
const updateSegmentBBox = useCallback(() => { const updateSegmentBBox = useCallback(() => {
if (!characterGroupRef.current) return if (!characterGroupRef.current) return
characterSegment.current.start.set(0, capsuleLength / 2, 0).add(characterGroupRef.current.position) characterSegment.current.start
characterSegment.current.end.set(0, -capsuleLength / 2, 0).add(characterGroupRef.current.position) .set(0, capsuleLength / 2, 0)
.add(characterGroupRef.current.position)
characterSegment.current.end
.set(0, -capsuleLength / 2, 0)
.add(characterGroupRef.current.position)
characterBbox.current characterBbox.current
.makeEmpty() .makeEmpty()
@@ -394,11 +402,18 @@ const BVHEcctrl = forwardRef<BVHEcctrlApi, EcctrlProps>(
const collisionCheck = useCallback( const collisionCheck = useCallback(
(mesh: THREE.Mesh, originMatrix: THREE.Matrix4, delta: number) => { (mesh: THREE.Mesh, originMatrix: THREE.Matrix4, delta: number) => {
if (!mesh.visible || !mesh.geometry.boundsTree || mesh.userData.excludeCollisionCheck) return if (!(mesh.visible && mesh.geometry.boundsTree) || mesh.userData.excludeCollisionCheck)
return
originMatrix.decompose(contactTempPos.current, contactTempQuat.current, contactTempScale.current) originMatrix.decompose(
contactTempPos.current,
contactTempQuat.current,
contactTempScale.current,
)
collideInvertMatrix.current.copy(originMatrix).invert() collideInvertMatrix.current.copy(originMatrix).invert()
localCharacterSegment.current.copy(characterSegment.current).applyMatrix4(collideInvertMatrix.current) localCharacterSegment.current
.copy(characterSegment.current)
.applyMatrix4(collideInvertMatrix.current)
scaledContactRadiusVec.current.set( scaledContactRadiusVec.current.set(
capsuleRadius / contactTempScale.current.x, capsuleRadius / contactTempScale.current.x,
@@ -445,7 +460,10 @@ const BVHEcctrl = forwardRef<BVHEcctrlApi, EcctrlProps>(
contactDepth.current = contactDepth.current =
capsuleRadius - capsuleContactPoint.current.distanceTo(triContactPoint.current) capsuleRadius - capsuleContactPoint.current.distanceTo(triContactPoint.current)
accumulatedContactNormal.current.addScaledVector(contactNormal.current, contactDepth.current) accumulatedContactNormal.current.addScaledVector(
contactNormal.current,
contactDepth.current,
)
accumulatedContactPoint.current.add(triContactPoint.current) accumulatedContactPoint.current.add(triContactPoint.current)
totalDepth.current += contactDepth.current totalDepth.current += contactDepth.current
triangleCount.current += 1 triangleCount.current += 1
@@ -492,14 +510,16 @@ const BVHEcctrl = forwardRef<BVHEcctrlApi, EcctrlProps>(
const floatingCheck = useCallback( const floatingCheck = useCallback(
(mesh: THREE.Mesh, originMatrix: THREE.Matrix4) => { (mesh: THREE.Mesh, originMatrix: THREE.Matrix4) => {
if (!mesh.visible || !mesh.geometry.boundsTree || mesh.userData.excludeFloatHit) return if (!(mesh.visible && mesh.geometry.boundsTree) || mesh.userData.excludeFloatHit) return
originMatrix.decompose(floatTempPos.current, floatTempQuat.current, floatTempScale.current) originMatrix.decompose(floatTempPos.current, floatTempQuat.current, floatTempScale.current)
floatInvertMatrix.current.copy(originMatrix).invert() floatInvertMatrix.current.copy(originMatrix).invert()
floatNormalInverseMatrix.current.getNormalMatrix(floatInvertMatrix.current) floatNormalInverseMatrix.current.getNormalMatrix(floatInvertMatrix.current)
floatNormalMatrix.current.getNormalMatrix(originMatrix) floatNormalMatrix.current.getNormalMatrix(originMatrix)
localFloatSensorSegment.current.copy(floatSensorSegment.current).applyMatrix4(floatInvertMatrix.current) localFloatSensorSegment.current
.copy(floatSensorSegment.current)
.applyMatrix4(floatInvertMatrix.current)
localFloatSensorBboxExpendPoint.current localFloatSensorBboxExpendPoint.current
.copy(floatSensorBboxExpendPoint.current) .copy(floatSensorBboxExpendPoint.current)
.applyMatrix4(floatInvertMatrix.current) .applyMatrix4(floatInvertMatrix.current)
@@ -517,20 +537,33 @@ const BVHEcctrl = forwardRef<BVHEcctrlApi, EcctrlProps>(
localFloatSensorBbox.current.min.addScaledVector(scaledFloatRadiusVec.current, -1) localFloatSensorBbox.current.min.addScaledVector(scaledFloatRadiusVec.current, -1)
localFloatSensorBbox.current.max.add(scaledFloatRadiusVec.current) localFloatSensorBbox.current.max.add(scaledFloatRadiusVec.current)
localMinDistance.current = Infinity localMinDistance.current = Number.POSITIVE_INFINITY
localClosestPoint.current.set(Infinity, Infinity, Infinity) localClosestPoint.current.set(
Number.POSITIVE_INFINITY,
Number.POSITIVE_INFINITY,
Number.POSITIVE_INFINITY,
)
mesh.geometry.boundsTree.shapecast({ mesh.geometry.boundsTree.shapecast({
intersectsBounds: (box) => box.intersectsBox(localFloatSensorBbox.current), intersectsBounds: (box) => box.intersectsBox(localFloatSensorBbox.current),
intersectsTriangle: (tri) => { intersectsTriangle: (tri) => {
tri.closestPointToSegment(localFloatSensorSegment.current, triHitPoint.current, segHitPoint.current) tri.closestPointToSegment(
localUpAxis.current.copy(upAxis.current).applyMatrix3(floatNormalInverseMatrix.current).normalize() localFloatSensorSegment.current,
triHitPoint.current,
segHitPoint.current,
)
localUpAxis.current
.copy(upAxis.current)
.applyMatrix3(floatNormalInverseMatrix.current)
.normalize()
deltaHit.current.subVectors(triHitPoint.current, localFloatSensorSegment.current.start) deltaHit.current.subVectors(triHitPoint.current, localFloatSensorSegment.current.start)
deltaHit.current.divide(scaledFloatRadiusVec.current) deltaHit.current.divide(scaledFloatRadiusVec.current)
const totalLengthSq = deltaHit.current.lengthSq() const totalLengthSq = deltaHit.current.lengthSq()
const dot = deltaHit.current.dot(localUpAxis.current) const dot = deltaHit.current.dot(localUpAxis.current)
const verticalLength = Math.abs(dot) / ((capsuleRadius + floatHeight + floatPullBackHeight) / floatSensorRadius) const verticalLength =
Math.abs(dot) /
((capsuleRadius + floatHeight + floatPullBackHeight) / floatSensorRadius)
const horizontalLength = Math.sqrt(Math.max(0, totalLengthSq - dot * dot)) const horizontalLength = Math.sqrt(Math.max(0, totalLengthSq - dot * dot))
if (horizontalLength < 1 && verticalLength < 1) { if (horizontalLength < 1 && verticalLength < 1) {
@@ -560,9 +593,14 @@ const BVHEcctrl = forwardRef<BVHEcctrlApi, EcctrlProps>(
const handleFloatingResponse = useCallback( const handleFloatingResponse = useCallback(
(meshes: THREE.Mesh[], jump: boolean, delta: number) => { (meshes: THREE.Mesh[], jump: boolean, delta: number) => {
if (meshes.length === 0) return if (meshes.length === 0) return
let shouldJump = jump
globalMinDistance.current = Infinity globalMinDistance.current = Number.POSITIVE_INFINITY
globalClosestPoint.current.set(Infinity, Infinity, Infinity) globalClosestPoint.current.set(
Number.POSITIVE_INFINITY,
Number.POSITIVE_INFINITY,
Number.POSITIVE_INFINITY,
)
floatHitNormal.current.set(0, 1, 0) floatHitNormal.current.set(0, 1, 0)
isOnGround.current = false isOnGround.current = false
totalPlatformDeltaPos.current.set(0, 0, 0) totalPlatformDeltaPos.current.set(0, 0, 0)
@@ -574,7 +612,11 @@ const BVHEcctrl = forwardRef<BVHEcctrlApi, EcctrlProps>(
} }
} }
if (floatCheckType !== 'SHAPECAST' && floatRaycastCandidates.length > 0 && globalMinDistance.current === Infinity) { if (
floatCheckType !== 'SHAPECAST' &&
floatRaycastCandidates.length > 0 &&
globalMinDistance.current === Number.POSITIVE_INFINITY
) {
floatRaycaster.current.ray.origin.copy(floatSensorSegment.current.start) floatRaycaster.current.ray.origin.copy(floatSensorSegment.current.start)
floatRaycaster.current.ray.direction.copy(gravityDir.current) floatRaycaster.current.ray.direction.copy(gravityDir.current)
const hits = floatRaycaster.current.intersectObjects(floatRaycastCandidates, false) const hits = floatRaycaster.current.intersectObjects(floatRaycastCandidates, false)
@@ -582,23 +624,28 @@ const BVHEcctrl = forwardRef<BVHEcctrlApi, EcctrlProps>(
if (hit?.point) { if (hit?.point) {
globalClosestPoint.current.copy(hit.point) globalClosestPoint.current.copy(hit.point)
if (hit.face) { if (hit.face) {
floatHitNormal.current.copy(hit.face.normal).transformDirection(hit.object.matrixWorld).normalize() floatHitNormal.current
.copy(hit.face.normal)
.transformDirection(hit.object.matrixWorld)
.normalize()
} }
} }
} }
if (globalClosestPoint.current.x === Infinity) return if (globalClosestPoint.current.x === Number.POSITIVE_INFINITY) return
relativeHitPoint.current.copy(globalClosestPoint.current).sub(floatSensorSegment.current.start) relativeHitPoint.current
.copy(globalClosestPoint.current)
.sub(floatSensorSegment.current.start)
const currentDistance = relativeHitPoint.current.length() const currentDistance = relativeHitPoint.current.length()
currSlopeAngle.current = floatHitNormal.current.angleTo(upAxis.current) currSlopeAngle.current = floatHitNormal.current.angleTo(upAxis.current)
if (currentDistance < floatHeight + capsuleRadius) { if (currentDistance < floatHeight + capsuleRadius) {
isOnGround.current = true isOnGround.current = true
jump = false shouldJump = false
} }
if (!jump) { if (!shouldJump) {
const displacement = floatHeight + capsuleRadius - currentDistance const displacement = floatHeight + capsuleRadius - currentDistance
const velocityOnHitNormal = currentLinVel.current.dot(floatHitNormal.current) const velocityOnHitNormal = currentLinVel.current.dot(floatHitNormal.current)
const springForce = displacement * floatSpringK const springForce = displacement * floatSpringK
@@ -608,7 +655,17 @@ const BVHEcctrl = forwardRef<BVHEcctrlApi, EcctrlProps>(
currentLinVel.current.addScaledVector(floatHitNormal.current, (totalForce / mass) * delta) currentLinVel.current.addScaledVector(floatHitNormal.current, (totalForce / mass) * delta)
} }
}, },
[capsuleRadius, floatCheckType, floatDampingC, floatHeight, floatRaycastCandidates, floatSpringK, floatingCheck, gravity, mass], [
capsuleRadius,
floatCheckType,
floatDampingC,
floatHeight,
floatRaycastCandidates,
floatSpringK,
floatingCheck,
gravity,
mass,
],
) )
const updateCharacterWithPlatform = useCallback(() => { const updateCharacterWithPlatform = useCallback(() => {
@@ -646,8 +703,14 @@ const BVHEcctrl = forwardRef<BVHEcctrlApi, EcctrlProps>(
) )
const resetLinVel = useCallback(() => currentLinVel.current.set(0, 0, 0), []) const resetLinVel = useCallback(() => currentLinVel.current.set(0, 0, 0), [])
const addLinVel = useCallback((velocity: THREE.Vector3) => currentLinVel.current.add(velocity), []) const addLinVel = useCallback(
const setLinVel = useCallback((velocity: THREE.Vector3) => currentLinVel.current.copy(velocity), []) (velocity: THREE.Vector3) => currentLinVel.current.add(velocity),
[],
)
const setLinVel = useCallback(
(velocity: THREE.Vector3) => currentLinVel.current.copy(velocity),
[],
)
const setMovement = useCallback((movement: MovementInput) => { const setMovement = useCallback((movement: MovementInput) => {
if (movement.forward !== undefined) forwardState.current = movement.forward if (movement.forward !== undefined) forwardState.current = movement.forward
if (movement.backward !== undefined) backwardState.current = movement.backward if (movement.backward !== undefined) backwardState.current = movement.backward
@@ -682,7 +745,9 @@ const BVHEcctrl = forwardRef<BVHEcctrlApi, EcctrlProps>(
debugRaySensorEnd.current?.position.copy(floatSensorSegment.current.end) debugRaySensorEnd.current?.position.copy(floatSensorSegment.current.end)
standPointRef.current?.position.copy(globalClosestPoint.current) standPointRef.current?.position.copy(globalClosestPoint.current)
if (characterGroupRef.current) { if (characterGroupRef.current) {
lookDirRef.current?.position.copy(characterGroupRef.current.position).addScaledVector(upAxis.current, 0.7) lookDirRef.current?.position
.copy(characterGroupRef.current.position)
.addScaledVector(upAxis.current, 0.7)
} }
lookDirRef.current?.lookAt(lookDirRef.current.position.clone().add(camProjDir.current)) lookDirRef.current?.lookAt(lookDirRef.current.position.clone().add(camProjDir.current))
inputDirRef.current?.position.copy(characterSegment.current.end) inputDirRef.current?.position.copy(characterSegment.current.end)
@@ -698,7 +763,7 @@ const BVHEcctrl = forwardRef<BVHEcctrlApi, EcctrlProps>(
if (paused || elapsedRef.current < delay) return if (paused || elapsedRef.current < delay) return
const deltaTime = Math.min(1 / 45, delta) * slowMotionFactor const deltaTime = Math.min(1 / 45, delta) * slowMotionFactor
const keys = isInsideKeyboardControls && getKeys ? getKeys() : presetKeys const keys = getKeys() ?? presetKeys
const forward = forwardState.current || keys.forward const forward = forwardState.current || keys.forward
const backward = backwardState.current || keys.backward const backward = backwardState.current || keys.backward
const leftward = leftwardState.current || keys.leftward const leftward = leftwardState.current || keys.leftward
@@ -740,7 +805,7 @@ const BVHEcctrl = forwardRef<BVHEcctrlApi, EcctrlProps>(
return ( return (
<Suspense fallback={null}> <Suspense fallback={null}>
<group {...props} ref={characterGroupRef} dispose={null}> <group {...props} dispose={null} ref={characterGroupRef}>
{debug && ( {debug && (
<mesh ref={characterColliderRef}> <mesh ref={characterColliderRef}>
<capsuleGeometry args={colliderCapsuleArgs} /> <capsuleGeometry args={colliderCapsuleArgs} />
@@ -777,8 +842,8 @@ const BVHEcctrl = forwardRef<BVHEcctrlApi, EcctrlProps>(
<octahedronGeometry args={[0.1, 0]} /> <octahedronGeometry args={[0.1, 0]} />
<meshNormalMaterial /> <meshNormalMaterial />
</mesh> </mesh>
<arrowHelper ref={inputDirRef} args={[undefined, undefined, undefined, '#00f']} /> <arrowHelper args={[undefined, undefined, undefined, '#00f']} ref={inputDirRef} />
<arrowHelper ref={moveDirRef} args={[undefined, undefined, undefined, '#f00']} /> <arrowHelper args={[undefined, undefined, undefined, '#f00']} ref={moveDirRef} />
<mesh ref={standPointRef}> <mesh ref={standPointRef}>
<octahedronGeometry args={[0.12, 0]} /> <octahedronGeometry args={[0.12, 0]} />
<meshBasicMaterial color="red" opacity={0.2} transparent /> <meshBasicMaterial color="red" opacity={0.2} transparent />
View File
@@ -4148,7 +4148,6 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
d={path} d={path}
fill={isDeleteHovered ? palette.deleteFill : palette.slabFill} fill={isDeleteHovered ? palette.deleteFill : palette.slabFill}
fillRule="evenodd" fillRule="evenodd"
opacity={slabFillOpacity}
onClick={ onClick={
canSelectSlabs canSelectSlabs
? (event) => { ? (event) => {
@@ -4167,9 +4166,10 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
} }
onPointerEnter={canSelectSlabs ? () => onSlabHoverChange(slab.id) : undefined} onPointerEnter={canSelectSlabs ? () => onSlabHoverChange(slab.id) : undefined}
onPointerLeave={canSelectSlabs ? () => onSlabHoverChange(null) : undefined} onPointerLeave={canSelectSlabs ? () => onSlabHoverChange(null) : undefined}
opacity={slabFillOpacity}
pointerEvents={canSelectSlabs ? undefined : 'none'} pointerEvents={canSelectSlabs ? undefined : 'none'}
style={canSelectSlabs ? { cursor: EDITOR_CURSOR } : undefined}
stroke="none" stroke="none"
style={canSelectSlabs ? { cursor: EDITOR_CURSOR } : undefined}
/> />
{isSelected && !isDeleteHovered ? ( {isSelected && !isDeleteHovered ? (
<path <path
@@ -4224,7 +4224,6 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
d={path} d={path}
fill={isDeleteHovered ? palette.deleteFill : palette.ceilingFill} fill={isDeleteHovered ? palette.deleteFill : palette.ceilingFill}
fillRule="evenodd" fillRule="evenodd"
opacity={ceilingFillOpacity}
onClick={ onClick={
canSelectCeilings canSelectCeilings
? (event) => { ? (event) => {
@@ -4245,9 +4244,10 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
canSelectCeilings ? () => onCeilingHoverChange(ceiling.id) : undefined canSelectCeilings ? () => onCeilingHoverChange(ceiling.id) : undefined
} }
onPointerLeave={canSelectCeilings ? () => onCeilingHoverChange(null) : undefined} onPointerLeave={canSelectCeilings ? () => onCeilingHoverChange(null) : undefined}
opacity={ceilingFillOpacity}
pointerEvents={canSelectCeilings ? undefined : 'none'} pointerEvents={canSelectCeilings ? undefined : 'none'}
style={canSelectCeilings ? { cursor: EDITOR_CURSOR } : undefined}
stroke="none" stroke="none"
style={canSelectCeilings ? { cursor: EDITOR_CURSOR } : undefined}
/> />
{isSelected && !isDeleteHovered ? ( {isSelected && !isDeleteHovered ? (
<path <path
@@ -4354,8 +4354,8 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
<polygon <polygon
fill={`url(#${wallSelectionHatchId})`} fill={`url(#${wallSelectionHatchId})`}
opacity={1} opacity={1}
points={points}
pointerEvents="none" pointerEvents="none"
points={points}
/> />
) : null} ) : null}
</g> </g>
@@ -4483,8 +4483,8 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
{canSelectGeometry && ( {canSelectGeometry && (
<polygon <polygon
fill="transparent" fill="transparent"
points={points}
pointerEvents="all" pointerEvents="all"
points={points}
stroke="transparent" stroke="transparent"
strokeWidth={FLOORPLAN_OPENING_HIT_STROKE_WIDTH} strokeWidth={FLOORPLAN_OPENING_HIT_STROKE_WIDTH}
vectorEffect="non-scaling-stroke" vectorEffect="non-scaling-stroke"
@@ -4594,8 +4594,8 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
{canSelectGeometry && ( {canSelectGeometry && (
<polygon <polygon
fill="transparent" fill="transparent"
points={points}
pointerEvents="all" pointerEvents="all"
points={points}
stroke="transparent" stroke="transparent"
strokeWidth={FLOORPLAN_OPENING_HIT_STROKE_WIDTH} strokeWidth={FLOORPLAN_OPENING_HIT_STROKE_WIDTH}
vectorEffect="non-scaling-stroke" vectorEffect="non-scaling-stroke"
@@ -5274,8 +5274,8 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
{canSelectGeometry && ( {canSelectGeometry && (
<polygon <polygon
fill="transparent" fill="transparent"
points={points}
pointerEvents="all" pointerEvents="all"
points={points}
stroke="transparent" stroke="transparent"
strokeWidth={FLOORPLAN_OPENING_HIT_STROKE_WIDTH} strokeWidth={FLOORPLAN_OPENING_HIT_STROKE_WIDTH}
vectorEffect="non-scaling-stroke" vectorEffect="non-scaling-stroke"
@@ -5346,8 +5346,8 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
d={foldingPath} d={foldingPath}
fill="none" fill="none"
stroke={doorStroke} stroke={doorStroke}
strokeLinejoin="round"
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={isSelected || isSelectionHighlighted ? '1.8' : '1.25'} strokeWidth={isSelected || isSelectionHighlighted ? '1.8' : '1.25'}
vectorEffect="non-scaling-stroke" vectorEffect="non-scaling-stroke"
/> />
@@ -6025,6 +6025,12 @@ function FloorplanItemImage({
}) { }) {
const resolvedUrl = useResolvedAssetUrl(url) const resolvedUrl = useResolvedAssetUrl(url)
if (!resolvedUrl) return null if (!resolvedUrl) return null
// The PNG is captured with the modal's top-down camera (default up = +Y),
// so its pixel-right is world +X and pixel-up is world -Z. The plan SVG
// negates both axes (`toSvgX(v) = -v`, `toSvgY(v) = -v`), which together
// are a 180° rotation — so the captured image lands upside-down /
// mirrored when overlaid as-is. Bake that 180° into the image transform
// here; the panel / modal previews use the PNG directly and stay correct.
const rotationDeg = (-rotation * 180) / Math.PI + 180 const rotationDeg = (-rotation * 180) / Math.PI + 180
return ( return (
<g <g
@@ -6230,6 +6236,7 @@ const FloorplanNodeLayer = memo(function FloorplanNodeLayer({
} }
points={points} points={points}
stroke={stroke} stroke={stroke}
strokeOpacity={1}
strokeWidth={ strokeWidth={
isSelectionActive ? FLOORPLAN_SELECTED_WALL_STROKE_WIDTH : FLOORPLAN_WALL_STROKE_WIDTH isSelectionActive ? FLOORPLAN_SELECTED_WALL_STROKE_WIDTH : FLOORPLAN_WALL_STROKE_WIDTH
} }
@@ -6279,8 +6286,8 @@ const FloorplanNodeLayer = memo(function FloorplanNodeLayer({
<polygon <polygon
fill={`url(#${wallSelectionHatchId})`} fill={`url(#${wallSelectionHatchId})`}
opacity={1} opacity={1}
points={points}
pointerEvents="none" pointerEvents="none"
points={points}
/> />
) : null} ) : null}
{itemDimensionMeasurements.length > 0 ? ( {itemDimensionMeasurements.length > 0 ? (
@@ -6370,8 +6377,8 @@ const FloorplanNodeLayer = memo(function FloorplanNodeLayer({
<polygon <polygon
fill={fill} fill={fill}
fillOpacity={isDeleteHovered ? 0.82 : 0.92} fillOpacity={isDeleteHovered ? 0.82 : 0.92}
points={FLOORPLAN_SPAWN_ARROW_POINTS}
pointerEvents="none" pointerEvents="none"
points={FLOORPLAN_SPAWN_ARROW_POINTS}
stroke={stroke} stroke={stroke}
strokeLinejoin="round" strokeLinejoin="round"
strokeWidth={0.055} strokeWidth={0.055}
@@ -7286,6 +7293,11 @@ const FloorplanPolygonHandleLayer = memo(function FloorplanPolygonHandleLayer({
y2={endSvg.y} y2={endSvg.y}
/> />
<line <line
onPointerDown={
onEdgePointerDown
? (event) => onEdgePointerDown(nodeId, edgeIndex, event)
: undefined
}
pointerEvents="stroke" pointerEvents="stroke"
stroke="transparent" stroke="transparent"
strokeLinecap="round" strokeLinecap="round"
@@ -7296,11 +7308,6 @@ const FloorplanPolygonHandleLayer = memo(function FloorplanPolygonHandleLayer({
x2={endSvg.x} x2={endSvg.x}
y1={startSvg.y} y1={startSvg.y}
y2={endSvg.y} y2={endSvg.y}
onPointerDown={
onEdgePointerDown
? (event) => onEdgePointerDown(nodeId, edgeIndex, event)
: undefined
}
/> />
</g> </g>
) )
@@ -9922,7 +9929,7 @@ export function FloorplanPanel() {
zoneVertexDragState != null || zoneVertexDragState != null ||
isPolygonDraftBuildActive isPolygonDraftBuildActive
if (!hasUserAdjustedViewportRef.current && !transientFloorplanFit) { if (!(hasUserAdjustedViewportRef.current || transientFloorplanFit)) {
setViewport((current) => setViewport((current) =>
floorplanViewportEquals(current, fittedViewport) ? current : fittedViewport, floorplanViewportEquals(current, fittedViewport) ? current : fittedViewport,
) )
@@ -11054,6 +11061,14 @@ export function FloorplanPanel() {
} }
}, [isItemPlacementPreviewActive, scheduleMovingFloorplanNodeRefresh]) }, [isItemPlacementPreviewActive, scheduleMovingFloorplanNodeRefresh])
useEffect(() => {
if (!hasPendingItemMeshFootprints) {
return
}
scheduleMovingFloorplanNodeRefresh()
}, [hasPendingItemMeshFootprints, scheduleMovingFloorplanNodeRefresh])
// Subscribe to the live-transforms store so rotation/position changes that // Subscribe to the live-transforms store so rotation/position changes that
// *don't* go through pointer events still refresh the floorplan — e.g. R/T // *don't* go through pointer events still refresh the floorplan — e.g. R/T
// keyboard rotation during placement updates `useLiveTransforms` but emits // keyboard rotation during placement updates `useLiveTransforms` but emits
@@ -11069,14 +11084,6 @@ export function FloorplanPanel() {
return unsubscribe return unsubscribe
}, [isItemPlacementPreviewActive, scheduleMovingFloorplanNodeRefresh]) }, [isItemPlacementPreviewActive, scheduleMovingFloorplanNodeRefresh])
useEffect(() => {
if (!hasPendingItemMeshFootprints) {
return
}
scheduleMovingFloorplanNodeRefresh()
}, [hasPendingItemMeshFootprints, scheduleMovingFloorplanNodeRefresh])
useEffect(() => { useEffect(() => {
if (!(movingNode?.type === 'door' || movingNode?.type === 'window')) { if (!(movingNode?.type === 'door' || movingNode?.type === 'window')) {
return return
@@ -16217,17 +16224,13 @@ export function FloorplanPanel() {
onDuplicate: handleSelectedItemDuplicate, onDuplicate: handleSelectedItemDuplicate,
onMove: handleSelectedItemMove, onMove: handleSelectedItemMove,
}} }}
offsetY={FLOORPLAN_ACTION_MENU_OFFSET_Y}
opening={{ opening={{
position: selectedOpeningActionMenuPosition, position: selectedOpeningActionMenuPosition,
onDelete: handleSelectedOpeningDelete, onDelete: handleSelectedOpeningDelete,
onDuplicate: handleSelectedOpeningDuplicate, onDuplicate: handleSelectedOpeningDuplicate,
onMove: handleSelectedOpeningMove, onMove: handleSelectedOpeningMove,
}} }}
spawn={{
position: selectedSpawnActionMenuPosition,
onDelete: handleSelectedSpawnDelete,
onMove: handleSelectedSpawnMove,
}}
roof={{ roof={{
position: selectedRoofActionMenuPosition, position: selectedRoofActionMenuPosition,
onDelete: handleSelectedRoofDelete, onDelete: handleSelectedRoofDelete,
@@ -16242,6 +16245,11 @@ export function FloorplanPanel() {
: handleSelectedSlabDelete, : handleSelectedSlabDelete,
onMove: selectedSlabEditingHole ? handleSelectedSlabHoleMove : handleSelectedSlabMove, onMove: selectedSlabEditingHole ? handleSelectedSlabHoleMove : handleSelectedSlabMove,
}} }}
spawn={{
position: selectedSpawnActionMenuPosition,
onDelete: handleSelectedSpawnDelete,
onMove: handleSelectedSpawnMove,
}}
stair={{ stair={{
position: selectedStairActionMenuPosition, position: selectedStairActionMenuPosition,
onDelete: handleSelectedStairDelete, onDelete: handleSelectedStairDelete,
@@ -16253,7 +16261,6 @@ export function FloorplanPanel() {
onDelete: handleSelectedWallDelete, onDelete: handleSelectedWallDelete,
onMove: handleSelectedWallMove, onMove: handleSelectedWallMove,
}} }}
offsetY={FLOORPLAN_ACTION_MENU_OFFSET_Y}
/> />
{referenceScaleDraft && ( {referenceScaleDraft && (
@@ -16286,7 +16293,7 @@ export function FloorplanPanel() {
</div> </div>
<div className="mb-3 rounded-xl border border-border/70 bg-white/5 px-3 py-2"> <div className="mb-3 rounded-xl border border-border/70 bg-white/5 px-3 py-2">
<div className="text-muted-foreground text-[11px] uppercase tracking-wide"> <div className="text-[11px] text-muted-foreground uppercase tracking-wide">
Drawn line Drawn line
</div> </div>
<div className="mt-1 font-medium text-sm"> <div className="mt-1 font-medium text-sm">
@@ -16448,8 +16455,8 @@ export function FloorplanPanel() {
<FloorplanGuideLayer <FloorplanGuideLayer
activeGuideInteractionGuideId={activeGuideInteractionGuideId} activeGuideInteractionGuideId={activeGuideInteractionGuideId}
activeGuideInteractionMode={activeGuideInteractionMode} activeGuideInteractionMode={activeGuideInteractionMode}
guideUi={guideUi}
guides={displayGuides} guides={displayGuides}
guideUi={guideUi}
isInteractive={canInteractWithGuides} isInteractive={canInteractWithGuides}
onGuideSelect={handleGuideSelect} onGuideSelect={handleGuideSelect}
onGuideTranslateStart={handleGuideTranslateStart} onGuideTranslateStart={handleGuideTranslateStart}
@@ -16470,6 +16477,8 @@ export function FloorplanPanel() {
hoveredSlabId={hoveredSlabId} hoveredSlabId={hoveredSlabId}
hoveredWallId={hoveredWallId} hoveredWallId={hoveredWallId}
isDeleteMode={isDeleteMode} isDeleteMode={isDeleteMode}
isGuideTraceVisible={isGuideTraceVisible}
metersPerUnit={calibratedMetersPerUnit}
onCeilingDoubleClick={handleCeilingDoubleClick} onCeilingDoubleClick={handleCeilingDoubleClick}
onCeilingHoverChange={handleCeilingHoverChange} onCeilingHoverChange={handleCeilingHoverChange}
onCeilingSelect={handleCeilingSelect} onCeilingSelect={handleCeilingSelect}
@@ -16486,11 +16495,9 @@ export function FloorplanPanel() {
openingsPolygons={openingsPolygons} openingsPolygons={openingsPolygons}
palette={palette} palette={palette}
selectedIdSet={selectedIdSet} selectedIdSet={selectedIdSet}
slabSelectionHatchId={slabSelectionHatchId}
slabPolygons={displaySlabPolygons} slabPolygons={displaySlabPolygons}
slabSelectionHatchId={slabSelectionHatchId}
unit={unit} unit={unit}
metersPerUnit={calibratedMetersPerUnit}
isGuideTraceVisible={isGuideTraceVisible}
wallPolygons={displayWallPolygons} wallPolygons={displayWallPolygons}
wallSelectionHatchId={wallSelectionHatchId} wallSelectionHatchId={wallSelectionHatchId}
/> />
@@ -16568,8 +16575,8 @@ export function FloorplanPanel() {
<FloorplanReferenceScaleLayer <FloorplanReferenceScaleLayer
draft={referenceScaleDraft} draft={referenceScaleDraft}
guideUi={guideUi}
guides={displayGuides} guides={displayGuides}
guideUi={guideUi}
palette={palette} palette={palette}
unit={unit} unit={unit}
unitsPerPixel={floorplanUnitsPerPixel} unitsPerPixel={floorplanUnitsPerPixel}
+68 -53
View File
@@ -20,6 +20,7 @@ import {
import { ViewerOverlay } from '../../components/viewer-overlay' import { ViewerOverlay } from '../../components/viewer-overlay'
import { ViewerZoneSystem } from '../../components/viewer-zone-system' import { ViewerZoneSystem } from '../../components/viewer-zone-system'
import { type PresetsAdapter, PresetsProvider } from '../../contexts/presets-context' import { type PresetsAdapter, PresetsProvider } from '../../contexts/presets-context'
import { useAutoFrame } from '../../hooks/use-auto-frame'
import { type SaveStatus, useAutoSave } from '../../hooks/use-auto-save' import { type SaveStatus, useAutoSave } from '../../hooks/use-auto-save'
import { useKeyboard } from '../../hooks/use-keyboard' import { useKeyboard } from '../../hooks/use-keyboard'
import { import {
@@ -64,6 +65,7 @@ import { Grid } from './grid'
import { PresetThumbnailGenerator } from './preset-thumbnail-generator' import { PresetThumbnailGenerator } from './preset-thumbnail-generator'
import { SelectionManager } from './selection-manager' import { SelectionManager } from './selection-manager'
import { SiteEdgeLabels } from './site-edge-labels' import { SiteEdgeLabels } from './site-edge-labels'
import { SnapshotCaptureOverlay } from './snapshot-capture-overlay'
import { type SnapshotCameraData, ThumbnailGenerator } from './thumbnail-generator' import { type SnapshotCameraData, ThumbnailGenerator } from './thumbnail-generator'
import { WallMeasurementLabel } from './wall-measurement-label' import { WallMeasurementLabel } from './wall-measurement-label'
@@ -76,12 +78,12 @@ const PAINT_CURSOR_BADGE_DISABLED_COLOR = '#94a3b8'
const PAINT_CURSOR_BADGE_OFFSET_X = 14 const PAINT_CURSOR_BADGE_OFFSET_X = 14
const PAINT_CURSOR_BADGE_OFFSET_Y = 14 const PAINT_CURSOR_BADGE_OFFSET_Y = 14
const EDITOR_HOVER_STYLES: HoverStyles = { const EDITOR_HOVER_STYLES: HoverStyles = {
default: { visibleColor: 0x00_aaff, hiddenColor: 0xf3_ff47, strength: 5, pulse: true }, default: { visibleColor: 0x00_aa_ff, hiddenColor: 0xf3_ff_47, strength: 5, pulse: true },
delete: { visibleColor: 0xef_4444, hiddenColor: 0x99_1b1b, strength: 6, pulse: false }, delete: { visibleColor: 0xef_44_44, hiddenColor: 0x99_1b_1b, strength: 6, pulse: false },
'paint-ready': { visibleColor: 0xf5_9e0b, hiddenColor: 0xfd_e068, strength: 5, pulse: true }, 'paint-ready': { visibleColor: 0xf5_9e_0b, hiddenColor: 0xfd_e0_68, strength: 5, pulse: true },
'paint-disabled': { 'paint-disabled': {
visibleColor: 0x94_a3b8, visibleColor: 0x94_a3_b8,
hiddenColor: 0x47_5569, hiddenColor: 0x47_55_69,
strength: 4, strength: 4,
pulse: false, pulse: false,
}, },
@@ -462,7 +464,7 @@ function ViewerCanvasControlsHint({
<div className="pointer-events-none absolute top-14 left-1/2 z-40 max-w-[calc(100%-2rem)] -translate-x-1/2"> <div className="pointer-events-none absolute top-14 left-1/2 z-40 max-w-[calc(100%-2rem)] -translate-x-1/2">
<section <section
aria-label="Camera controls hint" aria-label="Camera controls hint"
className="pointer-events-auto flex items-start gap-3 rounded-2xl border border-border/35 bg-background/90 px-3.5 py-2.5 shadow-[0_22px_40px_-28px_rgba(15,23,42,0.65),0_10px_24px_-20px_rgba(15,23,42,0.55)] backdrop-blur-xl" className="pointer-events-auto flex items-start gap-3 rounded-2xl border border-border/35 bg-background/90 px-3.5 py-2.5 shadow-elevation-4 backdrop-blur-xl"
> >
<div className="grid min-w-0 flex-1 grid-cols-3 items-start divide-x divide-border/18"> <div className="grid min-w-0 flex-1 grid-cols-3 items-start divide-x divide-border/18">
{hints.map((hint) => ( {hints.map((hint) => (
@@ -753,7 +755,7 @@ function PaintCursorLayer({
(activePaintMaterial.material !== undefined || (activePaintMaterial.material !== undefined ||
activePaintMaterial.materialPreset !== undefined), activePaintMaterial.materialPreset !== undefined),
) )
const label = !hasMaterial ? 'Choose material' : `Paint ${activePaintTarget}` const label = hasMaterial ? `Paint ${activePaintTarget}` : 'Choose material'
const icon = 'mdi:format-color-fill' const icon = 'mdi:format-color-fill'
useLayoutEffect(() => { useLayoutEffect(() => {
@@ -786,16 +788,16 @@ function PaintCursorLayer({
const ViewerCanvas = memo(function ViewerCanvas({ const ViewerCanvas = memo(function ViewerCanvas({
isVersionPreviewMode, isVersionPreviewMode,
isLoading, isLoading,
isFirstPersonMode,
hasLoadedInitialScene, hasLoadedInitialScene,
showLoader, showLoader,
isFirstPersonMode,
onThumbnailCapture, onThumbnailCapture,
}: { }: {
isVersionPreviewMode: boolean isVersionPreviewMode: boolean
isLoading: boolean isLoading: boolean
isFirstPersonMode: boolean
hasLoadedInitialScene: boolean hasLoadedInitialScene: boolean
showLoader: boolean showLoader: boolean
isFirstPersonMode: boolean
onThumbnailCapture?: (blob: Blob, cameraData: SnapshotCameraData) => void onThumbnailCapture?: (blob: Blob, cameraData: SnapshotCameraData) => void
}) { }) {
const viewMode = useEditor((s) => s.viewMode) const viewMode = useEditor((s) => s.viewMode)
@@ -837,7 +839,7 @@ const ViewerCanvas = memo(function ViewerCanvas({
window.removeEventListener('pointermove', handlePointerMove) window.removeEventListener('pointermove', handlePointerMove)
window.removeEventListener('pointerup', handlePointerUp) window.removeEventListener('pointerup', handlePointerUp)
} }
}, [setFloorplanPaneRatio]) }, [])
useEffect(() => { useEffect(() => {
setIsCameraControlsHintVisible(!readCameraControlsHintDismissed()) setIsCameraControlsHintVisible(!readCameraControlsHintDismissed())
@@ -940,7 +942,9 @@ export default function Editor({
commandPaletteEmptyAction, commandPaletteEmptyAction,
}: EditorProps) { }: EditorProps) {
const isFirstPersonMode = useEditor((s) => s.isFirstPersonMode) const isFirstPersonMode = useEditor((s) => s.isFirstPersonMode)
useKeyboard({ isVersionPreviewMode, disabled: isFirstPersonMode }) useKeyboard({ isVersionPreviewMode, disabled: isFirstPersonMode })
const { isLoadingSceneRef } = useAutoSave({ const { isLoadingSceneRef } = useAutoSave({
onSave, onSave,
onDirty, onDirty,
@@ -951,8 +955,7 @@ export default function Editor({
const [isSceneLoading, setIsSceneLoading] = useState(false) const [isSceneLoading, setIsSceneLoading] = useState(false)
const [hasLoadedInitialScene, setHasLoadedInitialScene] = useState(false) const [hasLoadedInitialScene, setHasLoadedInitialScene] = useState(false)
const isPreviewMode = useEditor((s) => s.isPreviewMode) const isPreviewMode = useEditor((s) => s.isPreviewMode)
const firstPersonPreviousLevelRef = useRef(useViewer.getState().selection.levelId) const isCaptureMode = useEditor((s) => s.isCaptureMode)
const wasFirstPersonModeRef = useRef(isFirstPersonMode)
const sidebarWidth = useSidebarStore((s) => s.width) const sidebarWidth = useSidebarStore((s) => s.width)
const isSidebarCollapsed = useSidebarStore((s) => s.isCollapsed) const isSidebarCollapsed = useSidebarStore((s) => s.isCollapsed)
@@ -970,39 +973,6 @@ export default function Editor({
} }
}, [projectId]) }, [projectId])
useEffect(() => {
const wasFirstPersonMode = wasFirstPersonModeRef.current
wasFirstPersonModeRef.current = isFirstPersonMode
if (isFirstPersonMode && !wasFirstPersonMode) {
const viewer = useViewer.getState()
firstPersonPreviousLevelRef.current = viewer.selection.levelId
viewer.setCameraMode('perspective')
viewer.setWallMode('up')
viewer.setWalkthroughMode(true)
viewer.setSelection({ selectedIds: [], zoneId: null })
return
}
if (!(wasFirstPersonMode && !isFirstPersonMode)) return
const viewer = useViewer.getState()
const previousLevelId = firstPersonPreviousLevelRef.current
firstPersonPreviousLevelRef.current = null
viewer.setWalkthroughMode(false)
if (!previousLevelId) return
const previousLevelNode = useScene.getState().nodes[previousLevelId]
if (previousLevelNode?.type === 'level') {
viewer.setSelection({
levelId: previousLevelId,
zoneId: null,
selectedIds: [],
})
}
}, [isFirstPersonMode])
// Load scene on mount (or when onLoad identity changes, e.g. project switch) // Load scene on mount (or when onLoad identity changes, e.g. project switch)
useEffect(() => { useEffect(() => {
let cancelled = false let cancelled = false
@@ -1064,6 +1034,42 @@ export default function Editor({
const showLoader = isLoading || isSceneLoading const showLoader = isLoading || isSceneLoading
const firstPersonPreviousLevelRef = useRef(useViewer.getState().selection.levelId)
const wasFirstPersonModeRef = useRef(isFirstPersonMode)
useEffect(() => {
const wasFirstPersonMode = wasFirstPersonModeRef.current
wasFirstPersonModeRef.current = isFirstPersonMode
if (isFirstPersonMode && !wasFirstPersonMode) {
const viewer = useViewer.getState()
firstPersonPreviousLevelRef.current = viewer.selection.levelId
viewer.setCameraMode('perspective')
viewer.setWallMode('up')
viewer.setWalkthroughMode(true)
viewer.setSelection({ selectedIds: [], zoneId: null })
return
}
if (!(wasFirstPersonMode && !isFirstPersonMode)) return
const viewer = useViewer.getState()
const previousLevelId = firstPersonPreviousLevelRef.current
firstPersonPreviousLevelRef.current = null
viewer.setWalkthroughMode(false)
if (!previousLevelId) return
const previousLevelNode = useScene.getState().nodes[previousLevelId]
if (previousLevelNode?.type === 'level') {
viewer.setSelection({
levelId: previousLevelId,
zoneId: null,
selectedIds: [],
})
}
}, [isFirstPersonMode])
const previewViewerContent = ( const previewViewerContent = (
<Viewer hoverStyles={EDITOR_HOVER_STYLES} selectionManager="default"> <Viewer hoverStyles={EDITOR_HOVER_STYLES} selectionManager="default">
<ExportManager /> <ExportManager />
@@ -1135,21 +1141,24 @@ export default function Editor({
navbarSlot={navbarSlot} navbarSlot={navbarSlot}
overlays={ overlays={
<> <>
<FloatingLevelSelector /> {!isCaptureMode && <FloatingLevelSelector />}
{!isVersionPreviewMode && ( {!(isVersionPreviewMode || isCaptureMode) && (
<div className="pointer-events-auto"> <div className="pointer-events-auto">
<ActionMenu /> <ActionMenu />
</div> </div>
)} )}
{!isVersionPreviewMode && ( {!(isVersionPreviewMode || isCaptureMode) && (
<div className="pointer-events-auto"> <div className="pointer-events-auto">
<PanelManager /> <PanelManager />
</div> </div>
)} )}
<div className="pointer-events-auto"> {!isCaptureMode && (
<HelperManager /> <div className="pointer-events-auto">
</div> <HelperManager />
</div>
)}
{viewerBanner} {viewerBanner}
{projectId ? <SnapshotCaptureOverlay projectId={projectId} /> : null}
</> </>
} }
renderTabContent={renderTabContent} renderTabContent={renderTabContent}
@@ -1159,14 +1168,14 @@ export default function Editor({
viewerToolbarLeft={viewerToolbarLeft} viewerToolbarLeft={viewerToolbarLeft}
viewerToolbarRight={viewerToolbarRight} viewerToolbarRight={viewerToolbarRight}
/> />
<EditorCommands />
<CommandPalette emptyAction={commandPaletteEmptyAction} />
{/* First-person overlay — rendered on top of normal layout */} {/* First-person overlay — rendered on top of normal layout */}
{isFirstPersonMode && ( {isFirstPersonMode && (
<div className="pointer-events-none fixed inset-0 z-50"> <div className="pointer-events-none fixed inset-0 z-50">
<FirstPersonOverlay onExit={() => useEditor.getState().setFirstPersonMode(false)} /> <FirstPersonOverlay onExit={() => useEditor.getState().setFirstPersonMode(false)} />
</div> </div>
)} )}
<EditorCommands />
<CommandPalette emptyAction={commandPaletteEmptyAction} />
</> </>
)} )}
</PresetsProvider> </PresetsProvider>
@@ -1222,6 +1231,12 @@ export default function Editor({
<HelperManager /> <HelperManager />
</div> </div>
</ViewerOverlays> </ViewerOverlays>
{/* First-person overlay — rendered on top of normal layout */}
{isFirstPersonMode && (
<div className="pointer-events-none fixed inset-0 z-50">
<FirstPersonOverlay onExit={() => useEditor.getState().setFirstPersonMode(false)} />
</div>
)}
</> </>
)} )}
</div> </div>
+2 -2
View File
@@ -341,7 +341,7 @@ function applySingleSurfacePaintPreview(
if (node.type === 'ceiling') { if (node.type === 'ceiling') {
const root = getRegisteredMesh(node.id) const root = getRegisteredMesh(node.id)
const overlay = root?.getObjectByName('ceiling-grid') as Mesh | undefined const overlay = root?.getObjectByName('ceiling-grid') as Mesh | undefined
if (!root || !overlay) return null if (!(root && overlay)) return null
const previewColor = const previewColor =
getMaterialPresetByRef(material.materialPreset)?.mapProperties.color ?? getMaterialPresetByRef(material.materialPreset)?.mapProperties.color ??
@@ -999,7 +999,6 @@ export const SelectionManager = () => {
'roof-segment', 'roof-segment',
'stair', 'stair',
'stair-segment', 'stair-segment',
'spawn',
'window', 'window',
'door', 'door',
'zone', 'zone',
@@ -1392,6 +1391,7 @@ export const SelectionManager = () => {
'roof-segment', 'roof-segment',
'stair', 'stair',
'stair-segment', 'stair-segment',
'spawn',
'window', 'window',
'door', 'door',
'zone', 'zone',
@@ -0,0 +1,465 @@
'use client'
import { emitter } from '@pascal-app/core'
import { Camera, Check, Crop, Loader2, Maximize2, Monitor, X } from 'lucide-react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useIsMobile } from '../../hooks/use-mobile'
import { triggerSFX } from '../../lib/sfx-bus'
import useEditor from '../../store/use-editor'
type CaptureMode = 'standard' | 'viewport' | 'area'
type CaptureState = 'idle' | 'capturing' | 'saved'
interface DragPoint {
x: number
y: number
}
interface Drag {
start: DragPoint
end: DragPoint
}
function getResolution(
mode: CaptureMode,
overlayEl: HTMLDivElement | null,
drag: Drag | null,
): { w: number; h: number } | null {
if (mode === 'standard') return { w: 1920, h: 1080 }
if (!overlayEl) return null
const rect = overlayEl.getBoundingClientRect()
const dpr = Math.min(window.devicePixelRatio, 1.5)
if (mode === 'viewport') {
return { w: Math.round(rect.width * dpr), h: Math.round(rect.height * dpr) }
}
if (mode === 'area' && drag) {
const w = Math.abs(drag.end.x - drag.start.x)
const h = Math.abs(drag.end.y - drag.start.y)
if (w < 4 || h < 4) return null
return { w: Math.round(w * dpr), h: Math.round(h * dpr) }
}
return null
}
export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) {
const isCaptureMode = useEditor((s) => s.isCaptureMode)
const setCaptureMode = useEditor((s) => s.setCaptureMode)
const isMobile = useIsMobile()
const [mode, setMode] = useState<CaptureMode>('standard')
const [drag, setDrag] = useState<Drag | null>(null)
const [isDragging, setIsDragging] = useState(false)
const [captureState, setCaptureState] = useState<CaptureState>('idle')
const overlayRef = useRef<HTMLDivElement>(null)
// Dismiss on Esc
useEffect(() => {
if (!isCaptureMode) return
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setCaptureMode(false)
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [isCaptureMode, setCaptureMode])
// Reset local state when entering capture mode
useEffect(() => {
if (isCaptureMode) {
setMode('standard')
setDrag(null)
setIsDragging(false)
setCaptureState('idle')
}
}, [isCaptureMode])
// Listen for snapshot saved to show feedback then exit
useEffect(() => {
const handler = () => {
setCaptureState('saved')
setTimeout(() => {
setCaptureMode(false)
setCaptureState('idle')
}, 1500)
}
emitter.on('snapshot:saved', handler)
return () => emitter.off('snapshot:saved', handler)
}, [setCaptureMode])
const dismiss = useCallback(() => setCaptureMode(false), [setCaptureMode])
// Tracks whether the active drag is a "move entire rect" gesture
const moveStartRef = useRef<{ pt: DragPoint; drag: Drag } | null>(null)
// Area drag handlers — relative to the overlay container
const onPointerDown = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
if (mode !== 'area' || captureState !== 'idle') return
e.preventDefault()
const rect = overlayRef.current!.getBoundingClientRect()
const pt = { x: e.clientX - rect.left, y: e.clientY - rect.top }
// If clicking inside an existing selection → move mode
if (drag) {
const x0 = Math.min(drag.start.x, drag.end.x)
const y0 = Math.min(drag.start.y, drag.end.y)
const x1 = Math.max(drag.start.x, drag.end.x)
const y1 = Math.max(drag.start.y, drag.end.y)
if (pt.x >= x0 && pt.x <= x1 && pt.y >= y0 && pt.y <= y1) {
moveStartRef.current = { pt, drag }
setIsDragging(true)
;(e.target as HTMLElement).setPointerCapture(e.pointerId)
return
}
}
// Outside / no selection → start new drag
moveStartRef.current = null
setDrag({ start: pt, end: pt })
setIsDragging(true)
;(e.target as HTMLElement).setPointerCapture(e.pointerId)
},
[mode, captureState, drag],
)
const onPointerMove = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
if (!isDragging) return
const rect = overlayRef.current!.getBoundingClientRect()
const pt = {
x: Math.max(0, Math.min(e.clientX - rect.left, rect.width)),
y: Math.max(0, Math.min(e.clientY - rect.top, rect.height)),
}
if (moveStartRef.current) {
// Move mode: translate the whole rect by the delta
const { pt: origin, drag: snapshot } = moveStartRef.current
const dx = pt.x - origin.x
const dy = pt.y - origin.y
setDrag({
start: { x: snapshot.start.x + dx, y: snapshot.start.y + dy },
end: { x: snapshot.end.x + dx, y: snapshot.end.y + dy },
})
} else {
setDrag((d) => (d ? { start: d.start, end: pt } : null))
}
},
[isDragging],
)
const onPointerUp = useCallback(() => {
const wasMoving = moveStartRef.current !== null
setIsDragging(false)
moveStartRef.current = null
// Clear the rect if the user just clicked without drawing (not a move gesture)
if (!wasMoving) {
setDrag((d) => {
if (!d) return null
const w = Math.abs(d.end.x - d.start.x)
const h = Math.abs(d.end.y - d.start.y)
return w < 4 && h < 4 ? null : d
})
}
}, [])
// Corner-handle resize: re-anchor to the opposite corner then reuse the same drag machinery
const onCornerPointerDown = useCallback(
(e: React.PointerEvent<HTMLDivElement>, cornerIndex: number) => {
if (captureState !== 'idle' || !drag) return
e.stopPropagation()
e.preventDefault()
moveStartRef.current = null
const x0 = Math.min(drag.start.x, drag.end.x)
const y0 = Math.min(drag.start.y, drag.end.y)
const x1 = Math.max(drag.start.x, drag.end.x)
const y1 = Math.max(drag.start.y, drag.end.y)
// anchor = opposite corner; dragged = current corner
const corners: [DragPoint, DragPoint][] = [
[
{ x: x1, y: y1 },
{ x: x0, y: y0 },
], // TL → anchor BR
[
{ x: x0, y: y1 },
{ x: x1, y: y0 },
], // TR → anchor BL
[
{ x: x1, y: y0 },
{ x: x0, y: y1 },
], // BL → anchor TR
[
{ x: x0, y: y0 },
{ x: x1, y: y1 },
], // BR → anchor TL
]
const [anchor, current] = corners[cornerIndex]!
setDrag({ start: anchor, end: current })
setIsDragging(true)
},
[captureState, drag],
)
const handleCapture = useCallback(() => {
if (captureState !== 'idle') return
let cropRegion: { x: number; y: number; width: number; height: number } | undefined
if (mode === 'area' && drag && overlayRef.current) {
const rect = overlayRef.current.getBoundingClientRect()
const x0 = Math.min(drag.start.x, drag.end.x)
const y0 = Math.min(drag.start.y, drag.end.y)
const w = Math.abs(drag.end.x - drag.start.x)
const h = Math.abs(drag.end.y - drag.start.y)
cropRegion = {
x: x0 / rect.width,
y: y0 / rect.height,
width: w / rect.width,
height: h / rect.height,
}
}
setCaptureState('capturing')
triggerSFX('sfx:snapshot-capture')
emitter.emit('camera-controls:generate-thumbnail', {
projectId,
captureMode: mode,
cropRegion,
})
}, [captureState, mode, drag, projectId])
if (!isCaptureMode) return null
const resolution = getResolution(mode, overlayRef.current, drag)
// Area selection rect (CSS px, relative to overlay)
const selectionStyle =
mode === 'area' && drag
? {
left: Math.min(drag.start.x, drag.end.x),
top: Math.min(drag.start.y, drag.end.y),
width: Math.abs(drag.end.x - drag.start.x),
height: Math.abs(drag.end.y - drag.start.y),
}
: null
const hasSelection =
selectionStyle != null && selectionStyle.width > 3 && selectionStyle.height > 3
const captureDisabled = captureState !== 'idle' || (mode === 'area' && !hasSelection)
return (
<div className="pointer-events-none absolute inset-0 z-40" ref={overlayRef}>
{/* Area mode: dim layer + crosshair cursor + drag-to-select */}
{mode === 'area' && (
<div
className="pointer-events-auto absolute inset-0 bg-black/30"
onPointerDown={onPointerDown}
onPointerMove={(e) => {
onPointerMove(e)
// Update cursor: 'move' when hovering inside an existing selection
if (!isDragging && drag && overlayRef.current) {
const rect = overlayRef.current.getBoundingClientRect()
const px = e.clientX - rect.left
const py = e.clientY - rect.top
const x0 = Math.min(drag.start.x, drag.end.x)
const y0 = Math.min(drag.start.y, drag.end.y)
const x1 = Math.max(drag.start.x, drag.end.x)
const y1 = Math.max(drag.start.y, drag.end.y)
e.currentTarget.style.cursor =
px >= x0 && px <= x1 && py >= y0 && py <= y1 ? 'move' : 'crosshair'
}
}}
onPointerUp={onPointerUp}
style={{ cursor: 'crosshair' }}
>
{/* "No selection" hint */}
{!selectionStyle && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<span className="rounded-full bg-black/40 px-4 py-2 text-sm text-white backdrop-blur-sm">
Drag the area you want to capture
</span>
</div>
)}
{/* Selection rect */}
{selectionStyle && (
<div
style={{
position: 'absolute',
left: selectionStyle.left,
top: selectionStyle.top,
width: selectionStyle.width,
height: selectionStyle.height,
pointerEvents: 'none',
boxShadow: '0 0 0 9999px rgba(0,0,0,0.35)',
border: '2px dashed rgba(255,255,255,0.85)',
background: 'rgba(255,255,255,0.04)',
}}
>
{/* Corner handles */}
{(
[
{ pos: { top: -5, left: -5 }, cursor: 'nwse-resize' },
{ pos: { top: -5, right: -5 }, cursor: 'nesw-resize' },
{ pos: { bottom: -5, left: -5 }, cursor: 'nesw-resize' },
{ pos: { bottom: -5, right: -5 }, cursor: 'nwse-resize' },
] as const
).map(({ pos, cursor }, i) => (
<div
key={i}
onPointerDown={(e) => onCornerPointerDown(e, i)}
style={{
position: 'absolute',
width: 10,
height: 10,
borderRadius: '50%',
background: 'white',
boxShadow: '0 1px 4px rgba(0,0,0,0.4)',
pointerEvents: 'auto',
cursor,
...pos,
}}
/>
))}
</div>
)}
</div>
)}
{/* Top-right dismiss button (icon-only on mobile) */}
<div className="pointer-events-auto absolute top-4 right-4">
<button
aria-label="Close capture mode"
className="flex items-center gap-1.5 rounded-full border border-white/20 bg-black/60 px-3 py-1.5 text-white/80 text-xs backdrop-blur-sm transition-colors hover:bg-black/80 hover:text-white"
onClick={dismiss}
type="button"
>
<X className="h-3 w-3" />
{!isMobile && 'Esc to cancel'}
</button>
</div>
{/* Bottom-center mode toolbar */}
<div className="pointer-events-auto absolute bottom-6 left-1/2 -translate-x-1/2">
{(() => {
const modeButtons = (
<>
<ModeButton
active={mode === 'standard'}
badge="16:9"
icon={<Monitor className="h-3.5 w-3.5" />}
label="Standard"
onClick={() => {
setMode('standard')
setDrag(null)
}}
/>
<ModeButton
active={mode === 'viewport'}
icon={<Maximize2 className="h-3.5 w-3.5" />}
label="Viewport"
onClick={() => {
setMode('viewport')
setDrag(null)
}}
/>
<ModeButton
active={mode === 'area'}
icon={<Crop className="h-3.5 w-3.5" />}
label="Area"
onClick={() => setMode('area')}
/>
</>
)
const resolutionDisplay = (
<span className="min-w-[80px] text-center text-white/50 text-xs tabular-nums">
{resolution ? `${resolution.w} × ${resolution.h}` : '—'}
</span>
)
const captureButton = (
<button
className="flex items-center gap-1.5 rounded-full bg-primary px-4 py-1.5 font-medium text-primary-foreground text-xs transition-opacity disabled:opacity-50"
disabled={captureDisabled}
onClick={handleCapture}
type="button"
>
{captureState === 'capturing' ? (
<>
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Capturing
</>
) : captureState === 'saved' ? (
<>
<Check className="h-3.5 w-3.5" />
Saved
</>
) : (
<>
<Camera className="h-3.5 w-3.5" />
Capture
</>
)}
</button>
)
if (isMobile) {
return (
<div className="flex flex-col items-stretch gap-2 rounded-2xl border border-white/10 bg-neutral-900/95 px-2 py-2 shadow-xl backdrop-blur-md">
<div className="flex items-center justify-center gap-1">{modeButtons}</div>
<div className="flex items-center justify-center gap-2 border-white/10 border-t pt-2">
{resolutionDisplay}
{captureButton}
</div>
</div>
)
}
return (
<div className="flex items-center gap-1 rounded-full border border-white/10 bg-neutral-900/95 px-2 py-2 shadow-xl backdrop-blur-md">
{modeButtons}
<div className="mx-1 h-4 w-px bg-white/10" />
{resolutionDisplay}
<div className="mx-1 h-4 w-px bg-white/10" />
{captureButton}
</div>
)
})()}
</div>
</div>
)
}
function ModeButton({
active,
icon,
label,
badge,
onClick,
}: {
active: boolean
icon: React.ReactNode
label: string
badge?: string
onClick: () => void
}) {
return (
<button
className={`flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs transition-colors ${
active ? 'bg-white/15 text-white ring-1 ring-white/20' : 'text-white/50 hover:text-white/90'
}`}
onClick={onClick}
type="button"
>
{icon}
{label}
{badge && (
<span className="rounded-sm bg-white/10 px-1 py-0.5 font-medium text-[10px] text-white/40 leading-none">
{badge}
</span>
)}
</button>
)
}
@@ -1,6 +1,6 @@
'use client' 'use client'
import { emitter, sceneRegistry, useScene } from '@pascal-app/core' import { emitter, sceneRegistry } from '@pascal-app/core'
import { SSGI_PARAMS, snapLevelsToTruePositions, useViewer } from '@pascal-app/viewer' import { SSGI_PARAMS, snapLevelsToTruePositions, useViewer } from '@pascal-app/viewer'
import type { CameraControls } from '@react-three/drei' import type { CameraControls } from '@react-three/drei'
import { useThree } from '@react-three/fiber' import { useThree } from '@react-three/fiber'
@@ -28,7 +28,6 @@ import { EDITOR_LAYER } from '../../lib/constants'
const THUMBNAIL_WIDTH = 1920 const THUMBNAIL_WIDTH = 1920
const THUMBNAIL_HEIGHT = 1080 const THUMBNAIL_HEIGHT = 1080
const AUTO_SAVE_DELAY = 10_000
export interface SnapshotCameraData { export interface SnapshotCameraData {
position: [number, number, number] position: [number, number, number]
@@ -49,8 +48,6 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
const mainCamera = useThree((state) => state.camera) const mainCamera = useThree((state) => state.camera)
const controls = useThree((state) => state.controls) as CameraControls | null const controls = useThree((state) => state.controls) as CameraControls | null
const isGenerating = useRef(false) const isGenerating = useRef(false)
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const pendingAutoRef = useRef(false)
const onThumbnailCaptureRef = useRef(onThumbnailCapture) const onThumbnailCaptureRef = useRef(onThumbnailCapture)
const thumbnailCameraRef = useRef<THREE.PerspectiveCamera | null>(null) const thumbnailCameraRef = useRef<THREE.PerspectiveCamera | null>(null)
@@ -61,7 +58,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
onThumbnailCaptureRef.current = onThumbnailCapture onThumbnailCaptureRef.current = onThumbnailCapture
}, [onThumbnailCapture]) }, [onThumbnailCapture])
// Build the thumbnail camera, SSGI pipeline, and render target once, reused on every capture. // Build the thumbnail camera, SSGI pipeline, and render target once reused on every capture.
useEffect(() => { useEffect(() => {
const cam = new THREE.PerspectiveCamera(60, THUMBNAIL_WIDTH / THUMBNAIL_HEIGHT, 0.1, 1000) const cam = new THREE.PerspectiveCamera(60, THUMBNAIL_WIDTH / THUMBNAIL_HEIGHT, 0.1, 1000)
cam.layers.disable(EDITOR_LAYER) cam.layers.disable(EDITOR_LAYER)
@@ -75,7 +72,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
if (!mounted) return if (!mounted) return
// pass() handles MRT internally for all material types, including custom // pass() handles MRT internally for all material types, including custom
// shaders, unlike renderer.setMRT() which crashes on non-NodeMaterials. // shaders unlike renderer.setMRT() which crashes on non-NodeMaterials.
// pass() also respects camera.layers, so EDITOR_LAYER objects are filtered. // pass() also respects camera.layers, so EDITOR_LAYER objects are filtered.
const scenePass = pass(scene, cam) const scenePass = pass(scene, cam)
scenePass.setMRT( scenePass.setMRT(
@@ -117,15 +114,15 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
const ao = (denoisePass as any).r const ao = (denoisePass as any).r
const finalOutput = vec4(scenePassColor.rgb.mul(ao), scenePassColor.a) const finalOutput = vec4(scenePassColor.rgb.mul(ao), scenePassColor.a)
// FXAA requires a texture node as input, convertToTexture renders finalOutput // FXAA requires a texture node as input; convertToTexture renders finalOutput
// into an intermediate RT so FXAA can sample it with neighbor UV offsets. // into an intermediate RT so FXAA can sample it with neighbour UV offsets.
const aaOutput = fxaa(convertToTexture(finalOutput)) const aaOutput = fxaa(convertToTexture(finalOutput))
const pipeline = new RenderPipeline(gl as unknown as WebGPURenderer) const pipeline = new RenderPipeline(gl as unknown as WebGPURenderer)
pipeline.outputNode = aaOutput pipeline.outputNode = aaOutput
pipelineRef.current = pipeline pipelineRef.current = pipeline
// Dedicated render target, pipeline outputs here instead of the canvas, // Dedicated render target pipeline outputs here instead of the canvas,
// so R3F's main render loop can never overwrite our capture. // so R3F's main render loop can never overwrite our capture.
const { width, height } = gl.domElement const { width, height } = gl.domElement
renderTargetRef.current = new RenderTarget(width, height, { depthBuffer: true }) renderTargetRef.current = new RenderTarget(width, height, { depthBuffer: true })
@@ -176,7 +173,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
thumbnailCamera.aspect = width / height thumbnailCamera.aspect = width / height
thumbnailCamera.updateProjectionMatrix() thumbnailCamera.updateProjectionMatrix()
// Capture camera data for snapshot storage. // Capture camera data for snapshot storage
const pos = mainCamera.position const pos = mainCamera.position
let tgt: [number, number, number] | null = null let tgt: [number, number, number] | null = null
if (controls && 'getTarget' in controls) { if (controls && 'getTarget' in controls) {
@@ -192,7 +189,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
...(isOrtho && { zoom: (mainCamera as THREE.OrthographicCamera).zoom }), ...(isOrtho && { zoom: (mainCamera as THREE.OrthographicCamera).zoom }),
} }
// For auto-save, snap levels to stacked positions and reset levelMode. // For auto-save: snap levels to stacked positions and reset levelMode
let restoreLevelMode: (() => void) | null = null let restoreLevelMode: (() => void) | null = null
let restoreLevels: () => void = () => {} let restoreLevels: () => void = () => {}
if (snapLevels) { if (snapLevels) {
@@ -205,7 +202,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
} }
// Hide scan and guide nodes directly so they are excluded from the // Hide scan and guide nodes directly so they are excluded from the
// thumbnail regardless of whether ScanSystem or GuideSystem listeners are // thumbnail regardless of whether ScanSystem/GuideSystem listeners are
// registered. Returns a function that restores the original visibility. // registered. Returns a function that restores the original visibility.
const restoreNodeVisibility = (() => { const restoreNodeVisibility = (() => {
const saved = new Map<THREE.Object3D, boolean>() const saved = new Map<THREE.Object3D, boolean>()
@@ -231,7 +228,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
if (pipelineRef.current && renderTargetRef.current) { if (pipelineRef.current && renderTargetRef.current) {
const rt = renderTargetRef.current const rt = renderTargetRef.current
// Resize RT if the canvas dimensions changed. // Resize RT if the canvas dimensions changed
if (rt.width !== width || rt.height !== height) { if (rt.width !== width || rt.height !== height) {
rt.setSize(width, height) rt.setSize(width, height)
} }
@@ -248,7 +245,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
emitter.emit('thumbnail:after-capture', undefined) emitter.emit('thumbnail:after-capture', undefined)
// Restore level positions, levelMode, and node visibility immediately after the // Restore level positions, levelMode, and node visibility immediately after the
// render, before the async GPU readback. // render before the async GPU readback.
restoreLevels() restoreLevels()
restoreLevelMode?.() restoreLevelMode?.()
restoreNodeVisibility() restoreNodeVisibility()
@@ -339,6 +336,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
offscreen.getContext('2d')!.drawImage(srcCanvas, sx, sy, outW, outH, 0, 0, outW, outH) offscreen.getContext('2d')!.drawImage(srcCanvas, sx, sy, outW, outH, 0, 0, outW, outH)
blob = await offscreen.convertToBlob({ type: 'image/png' }) blob = await offscreen.convertToBlob({ type: 'image/png' })
} else { } else {
// Standard: center-crop to 1920×1080 aspect ratio
const srcAspect = width / height const srcAspect = width / height
const dstAspect = THUMBNAIL_WIDTH / THUMBNAIL_HEIGHT const dstAspect = THUMBNAIL_WIDTH / THUMBNAIL_HEIGHT
let sx = 0, let sx = 0,
@@ -364,7 +362,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
if (captureMode !== undefined) cameraData.captureMode = captureMode if (captureMode !== undefined) cameraData.captureMode = captureMode
cameraData.resolution = { w: outW, h: outH } cameraData.resolution = { w: outW, h: outH }
} else { } else {
// Fallback: plain render directly to the canvas. // Fallback: plain render directly to the canvas
emitter.emit('thumbnail:before-capture', undefined) emitter.emit('thumbnail:before-capture', undefined)
gl.render(scene, thumbnailCamera) gl.render(scene, thumbnailCamera)
emitter.emit('thumbnail:after-capture', undefined) emitter.emit('thumbnail:after-capture', undefined)
@@ -450,10 +448,11 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
) )
// Thumbnail request via emitter. Two call shapes: // Thumbnail request via emitter. Two call shapes:
// - user-driven capture: `{ projectId, captureMode, cropRegion }`, captures // - user-driven capture: `{ projectId, captureMode, cropRegion }` captures
// the current pose with the supplied crop. // the current pose with the supplied crop.
// - auto-save capture: `{ projectId, snapLevels: true }`, snaps levels to // - host-driven auto-save: `{ projectId, snapLevels: true }` snaps levels
// their true positions first for a consistent auto-thumbnail angle. // to their true positions first for a consistent auto-thumbnail angle.
// The caller owns policy (when to fire, whether the tab is visible).
useEffect(() => { useEffect(() => {
if (!onThumbnailCapture) return if (!onThumbnailCapture) return
@@ -469,49 +468,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
return () => emitter.off('camera-controls:generate-thumbnail', handleGenerateThumbnail) return () => emitter.off('camera-controls:generate-thumbnail', handleGenerateThumbnail)
}, [generate, onThumbnailCapture]) }, [generate, onThumbnailCapture])
// OSS adaptation: keep local debounced auto-capture behavior because the // Go-to-camera: animate camera to a saved snapshot position/target
// community host-side autosave hook is not part of this repo.
useEffect(() => {
if (!onThumbnailCapture) return
const triggerNow = () => {
void generate(true)
}
const scheduleOrDefer = () => {
if (document.visibilityState === 'visible') {
triggerNow()
} else {
pendingAutoRef.current = true
}
}
const onSceneChange = () => {
if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current)
debounceTimerRef.current = setTimeout(scheduleOrDefer, AUTO_SAVE_DELAY)
}
const onVisibilityChange = () => {
if (document.visibilityState === 'visible' && pendingAutoRef.current) {
pendingAutoRef.current = false
triggerNow()
}
}
const unsubscribe = useScene.subscribe((state, prevState) => {
if (state.nodes !== prevState.nodes) onSceneChange()
})
document.addEventListener('visibilitychange', onVisibilityChange)
return () => {
if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current)
unsubscribe()
document.removeEventListener('visibilitychange', onVisibilityChange)
}
}, [generate, onThumbnailCapture])
// Go-to-camera: animate camera to a saved snapshot position or target.
useEffect(() => { useEffect(() => {
const handler = ({ const handler = ({
position, position,
@@ -144,11 +144,11 @@ export function useFloorplanBackgroundPlacement({
emitFloorplanGridEvent('click', snappedPoint, event) emitFloorplanGridEvent('click', snappedPoint, event)
setCursorPoint(snappedPoint) setCursorPoint(snappedPoint)
if (!roofDraftStart) { if (roofDraftStart) {
clearRoofPlacementDraft()
} else {
setRoofDraftStart(snappedPoint) setRoofDraftStart(snappedPoint)
setRoofDraftEnd(snappedPoint) setRoofDraftEnd(snappedPoint)
} else {
clearRoofPlacementDraft()
} }
return true return true
} }
View File
@@ -1,265 +0,0 @@
'use client'
import { useScene } from '@pascal-app/core'
import { ImageIcon, MessageSquare, X } from 'lucide-react'
import { useCallback, useRef, useState } from 'react'
import { Button } from './ui/primitives/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from './ui/primitives/dialog'
const MAX_IMAGES = 5
const MAX_IMAGE_SIZE = 5 * 1024 * 1024
type ImagePreview = { file: File; url: string }
export function FeedbackDialog({
projectId: projectIdProp,
onSubmit,
}: {
projectId?: string
onSubmit?: (data: {
message: string
projectId?: string
sceneGraph: unknown
images: File[]
}) => Promise<{ success: boolean; error?: string }>
}) {
const projectId = projectIdProp
const [open, setOpen] = useState(false)
const [message, setMessage] = useState('')
const [images, setImages] = useState<ImagePreview[]>([])
const [isDragging, setIsDragging] = useState(false)
const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
const [sent, setSent] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
const dragCounter = useRef(0)
const handleOpen = () => {
setOpen(true)
setSent(false)
setError(null)
setMessage('')
setImages([])
setIsDragging(false)
dragCounter.current = 0
}
const handleClose = () => {
if (isSubmitting) return
setOpen(false)
images.forEach((img) => {
URL.revokeObjectURL(img.url)
})
}
const addFiles = useCallback((files: FileList | File[]) => {
const incoming = Array.from(files).filter(
(f) => f.type.startsWith('image/') && f.size <= MAX_IMAGE_SIZE,
)
setImages((prev) => {
const remaining = MAX_IMAGES - prev.length
const added = incoming.slice(0, remaining).map((file) => ({
file,
url: URL.createObjectURL(file),
}))
return [...prev, ...added]
})
}, [])
const removeImage = (index: number) => {
setImages((prev) => {
const img = prev[index]
if (img) URL.revokeObjectURL(img.url)
return prev.filter((_, i) => i !== index)
})
}
// ── Drag handlers (on the entire dialog content) ──
const onDragEnter = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
dragCounter.current++
if (e.dataTransfer.types.includes('Files')) {
setIsDragging(true)
}
}
const onDragLeave = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
dragCounter.current--
if (dragCounter.current === 0) {
setIsDragging(false)
}
}
const onDragOver = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
}
const onDrop = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
dragCounter.current = 0
setIsDragging(false)
if (e.dataTransfer.files.length > 0) {
addFiles(e.dataTransfer.files)
}
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError(null)
setIsSubmitting(true)
try {
if (!onSubmit) return
const { nodes, rootNodeIds } = useScene.getState()
const sceneGraph = { nodes, rootNodeIds }
const result = await onSubmit({
message,
projectId,
sceneGraph,
images: images.map((img) => img.file),
})
if (result.success) {
setSent(true)
setTimeout(() => setOpen(false), 1500)
} else {
setError(result.error ?? 'Something went wrong')
}
} finally {
setIsSubmitting(false)
}
}
return (
<>
<button
className="flex items-center gap-2 rounded-lg border border-border bg-background/95 px-3 py-2 font-medium text-sm shadow-lg backdrop-blur-md transition-colors hover:bg-accent/90"
onClick={handleOpen}
>
<MessageSquare className="h-4 w-4" />
Feedback
</button>
<Dialog onOpenChange={handleClose} open={open}>
<DialogContent
className="sm:max-w-[460px]"
onDragEnter={onDragEnter}
onDragLeave={onDragLeave}
onDragOver={onDragOver}
onDrop={onDrop}
>
{/* Drag overlay — only visible when dragging files over the dialog */}
{isDragging && (
<div className="absolute inset-0 z-50 flex items-center justify-center rounded-lg border-2 border-primary/50 border-dashed bg-primary/5 backdrop-blur-sm transition-all">
<div className="flex flex-col items-center gap-2 text-primary/70">
<ImageIcon className="h-8 w-8" />
<p className="font-medium text-sm">Drop images here</p>
</div>
</div>
)}
<DialogHeader>
<DialogTitle>Send Feedback</DialogTitle>
<DialogDescription>We&apos;d love to hear your thoughts</DialogDescription>
</DialogHeader>
{sent ? (
<p className="py-4 text-center text-muted-foreground text-sm">
Thanks for your feedback!
</p>
) : (
<form className="space-y-4" onSubmit={handleSubmit}>
<div>
<label className="font-medium text-sm" htmlFor="feedback-message">
Your feedback
</label>
<textarea
autoFocus
className="mt-1 w-full resize-none rounded-md border border-border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary"
disabled={isSubmitting}
id="feedback-message"
onChange={(e) => setMessage(e.target.value)}
placeholder="Share your thoughts, suggestions, feature requests, or report issues..."
rows={5}
value={message}
/>
</div>
{/* Image thumbnails */}
{images.length > 0 && (
<div className="flex flex-wrap gap-2">
{images.map((img, i) => (
<div
className="group relative h-14 w-14 overflow-hidden rounded-md border border-border"
key={img.url}
>
<img alt="" className="h-full w-full object-cover" src={img.url} />
<button
className="absolute inset-0 flex items-center justify-center bg-black/50 opacity-0 transition-opacity group-hover:opacity-100"
onClick={() => removeImage(i)}
type="button"
>
<X className="h-4 w-4 text-white" />
</button>
</div>
))}
</div>
)}
{error && <p className="text-destructive text-sm">{error}</p>}
<div className="flex items-center justify-between">
{/* Subtle attach button */}
<button
className="flex items-center gap-1.5 text-muted-foreground text-xs transition-colors hover:text-foreground disabled:opacity-40"
disabled={isSubmitting || images.length >= MAX_IMAGES}
onClick={() => fileInputRef.current?.click()}
type="button"
>
<ImageIcon className="h-3.5 w-3.5" />
{images.length > 0 ? `${images.length}/${MAX_IMAGES}` : 'Attach'}
</button>
<input
accept="image/*"
className="hidden"
multiple
onChange={(e) => {
if (e.target.files) addFiles(e.target.files)
e.target.value = ''
}}
ref={fileInputRef}
type="file"
/>
<div className="flex gap-2">
<Button
disabled={isSubmitting}
onClick={handleClose}
type="button"
variant="outline"
>
Cancel
</Button>
<Button disabled={isSubmitting || !message.trim() || !onSubmit} type="submit">
{isSubmitting ? 'Sending...' : 'Send Feedback'}
</Button>
</div>
</div>
</form>
)}
</DialogContent>
</Dialog>
</>
)
}
@@ -1,280 +0,0 @@
'use client'
import { Howl } from 'howler'
import { Disc3, Settings2, SkipBack, SkipForward, Volume2, VolumeX } from 'lucide-react'
import { AnimatePresence, motion } from 'motion/react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { Slider } from '../components/ui/slider'
import { cn } from '../lib/utils'
import useAudio from '../store/use-audio'
const PLAYLIST = [
{
title: 'Ballroom in Miniature',
file: '/audios/radios/classic/Ballroom in Miniature.mp3',
},
{
title: 'Blueprints in Springtime',
file: '/audios/radios/classic/Blueprints in Springtime.mp3',
},
{
title: 'Clockwork Tea Party',
file: '/audios/radios/classic/Clockwork Tea Party.mp3',
},
{
title: 'Clockwork Tea Party (Alternate)',
file: '/audios/radios/classic/Clockwork Tea Party (Alternate).mp3',
},
{
title: 'Clockwork Teacups',
file: '/audios/radios/classic/Clockwork Teacups.mp3',
},
{
title: 'Evening in the Parlor',
file: '/audios/radios/classic/Evening in the Parlor.mp3',
},
{
title: 'Glass Atrium',
file: '/audios/radios/classic/Glass Atrium.mp3',
},
{
title: 'Moonlight On The Drafting Table',
file: '/audios/radios/classic/Moonlight On The Drafting Table.mp3',
},
{
title: 'Sunlit Garden Reverie',
file: '/audios/radios/classic/Sunlit Garden Reverie.mp3',
},
{
title: 'Sunlit Waltz in Pastel Hues',
file: '/audios/radios/classic/Sunlit Waltz in Pastel Hues.mp3',
},
]
// Shuffle array helper
function shuffleArray<T>(array: T[]): T[] {
const shuffled = [...array]
for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1))
;[shuffled[i], shuffled[j]] = [shuffled[j]!, shuffled[i]!]
}
return shuffled
}
export function PascalRadio() {
const [shuffledPlaylist] = useState(() => shuffleArray(PLAYLIST))
const [currentTrackIndex, setCurrentTrackIndex] = useState(0)
const { masterVolume, radioVolume, muted, isRadioPlaying, setRadioPlaying } = useAudio()
const soundRef = useRef<Howl | null>(null)
const [isOpen, setIsOpen] = useState(false)
const containerRef = useRef<HTMLDivElement>(null)
const currentTrack = shuffledPlaylist[currentTrackIndex]!
// Calculate effective volume (masterVolume * radioVolume, both are 0-100)
const effectiveVolume = (masterVolume / 100) * (radioVolume / 100)
// Keep a ref so the track-init effect can read current volume/muted/isRadioPlaying
// without those values being part of its dependency array (which would restart the song).
const effectiveVolumeRef = useRef(effectiveVolume)
const mutedRef = useRef(muted)
const isPlayingRef = useRef(isRadioPlaying)
effectiveVolumeRef.current = effectiveVolume
mutedRef.current = muted
isPlayingRef.current = isRadioPlaying
const handleNext = useCallback(() => {
setCurrentTrackIndex((prev) => (prev + 1) % shuffledPlaylist.length)
}, [shuffledPlaylist.length])
const handlePrevious = useCallback(() => {
setCurrentTrackIndex((prev) => (prev - 1 + shuffledPlaylist.length) % shuffledPlaylist.length)
}, [shuffledPlaylist.length])
// Initialize Howler only when the track changes — not on volume/mute/play-state changes.
// Volume and mute are handled by the separate effect below.
useEffect(() => {
if (soundRef.current) {
soundRef.current.unload()
}
const wasPlaying = isPlayingRef.current
soundRef.current = new Howl({
src: [currentTrack.file],
volume: mutedRef.current ? 0 : effectiveVolumeRef.current,
onend: handleNext,
})
if (wasPlaying && !mutedRef.current) {
soundRef.current?.play()
}
return () => {
soundRef.current?.unload()
}
}, [handleNext, currentTrack.file])
// Update volume when settings change
useEffect(() => {
if (soundRef.current) {
soundRef.current.volume(muted ? 0 : effectiveVolume)
// Pause if muted, resume if unmuted and was playing
if (muted && isRadioPlaying) {
soundRef.current.pause()
} else if (!muted && isRadioPlaying && !soundRef.current.playing()) {
soundRef.current.play()
} else if (!isRadioPlaying && soundRef.current.playing()) {
soundRef.current.pause()
}
}
}, [effectiveVolume, muted, isRadioPlaying])
const handlePlayPause = () => {
if (!soundRef.current || muted) return
if (isRadioPlaying) {
soundRef.current.pause()
} else {
soundRef.current.play()
}
setRadioPlaying(!isRadioPlaying)
}
const handleVolumeChange = (value: number[]) => {
useAudio.setState({ radioVolume: value[0] })
}
// Handle click outside to close
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setIsOpen(false)
}
}
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside)
}
return () => {
document.removeEventListener('mousedown', handleClickOutside)
}
}, [isOpen])
return (
<motion.div
className={cn(
'flex flex-col overflow-hidden rounded-lg border border-border bg-background/95 shadow-lg backdrop-blur-md',
!isOpen && 'cursor-pointer transition-colors hover:bg-accent/90',
)}
layout
onClick={() => {
if (!isOpen) setIsOpen(true)
}}
ref={containerRef}
transition={{ type: 'spring', bounce: 0.2, duration: 0.6 }}
>
<div className="flex items-center justify-between gap-2 px-3 py-2 font-medium text-sm">
<div className="flex items-center gap-2">
<Disc3 className={cn('h-4 w-4 shrink-0', isRadioPlaying && 'animate-spin')} />
<span className="hidden whitespace-nowrap sm:inline">Radio Pascal</span>
</div>
<div className="flex items-center gap-2">
<div
aria-label={isRadioPlaying ? 'Pause' : 'Play'}
className="cursor-pointer rounded-sm bg-accent/30 p-1 transition-all hover:bg-accent hover:text-accent-foreground hover:shadow-sm"
onClick={(e) => {
e.stopPropagation()
handlePlayPause()
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
e.stopPropagation()
handlePlayPause()
}
}}
role="button"
tabIndex={0}
>
{isRadioPlaying ? (
<Volume2 className="h-3.5 w-3.5" />
) : (
<VolumeX className="h-3.5 w-3.5" />
)}
</div>
<button
aria-label="Radio Settings"
className={cn(
'cursor-pointer rounded-sm p-1 transition-all hover:bg-accent hover:text-accent-foreground',
isOpen && 'bg-accent text-accent-foreground',
)}
onClick={(e) => {
e.stopPropagation()
setIsOpen(!isOpen)
}}
>
<Settings2 className="h-3.5 w-3.5" />
</button>
</div>
</div>
<AnimatePresence>
{isOpen && (
<motion.div
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
initial={{ opacity: 0, height: 0 }}
transition={{ type: 'spring', bounce: 0.2, duration: 0.6 }}
>
<div className="w-[16rem] space-y-3 px-3 pb-3">
<div className="mb-3 h-px w-full bg-border/50" />
{/* Current song info with prev/next */}
<div>
<p className="mb-2 text-muted-foreground text-xs">Now Playing</p>
<div className="flex items-center justify-between gap-2">
<button
aria-label="Previous"
className="shrink-0 rounded-full p-1.5 transition-colors hover:bg-accent"
onClick={handlePrevious}
>
<SkipBack className="h-4 w-4" />
</button>
<p
className="flex-1 truncate text-center font-medium text-sm"
title={currentTrack.title}
>
{currentTrack.title}
</p>
<button
aria-label="Next"
className="shrink-0 rounded-full p-1.5 transition-colors hover:bg-accent"
onClick={handleNext}
>
<SkipForward className="h-4 w-4" />
</button>
</div>
</div>
{/* Volume control */}
<div className="flex items-center gap-2">
<Volume2 className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<Slider
aria-label="Radio Volume"
className="flex-1"
max={100}
onValueChange={handleVolumeChange}
step={1}
value={[radioVolume]}
/>
<span className="w-8 shrink-0 text-right text-muted-foreground text-xs">
{radioVolume}%
</span>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
)
}
@@ -1,16 +0,0 @@
'use client'
import { Eye } from 'lucide-react'
import useEditor from '../store/use-editor'
export function PreviewButton() {
return (
<button
className="flex cursor-pointer items-center gap-2 rounded-lg border border-border bg-background/95 px-3 py-2 font-medium text-sm shadow-lg backdrop-blur-md transition-colors hover:bg-accent/90"
onClick={() => useEditor.getState().setPreviewMode(true)}
>
<Eye className="h-4 w-4 shrink-0" />
<span className="hidden whitespace-nowrap sm:inline">Preview</span>
</button>
)
}
@@ -75,7 +75,9 @@ const CeilingSelectionAffordance = ({
ceiling: CeilingNode ceiling: CeilingNode
levelId: string levelId: string
}) => { }) => {
const [levelObject, setLevelObject] = useState<Object3D | null>(() => sceneRegistry.nodes.get(levelId) ?? null) const [levelObject, setLevelObject] = useState<Object3D | null>(
() => sceneRegistry.nodes.get(levelId) ?? null,
)
const corners = useMemo(() => buildCornerBrackets(ceiling.polygon), [ceiling.polygon]) const corners = useMemo(() => buildCornerBrackets(ceiling.polygon), [ceiling.polygon])
@@ -110,11 +112,7 @@ const CeilingSelectionAffordance = ({
return createPortal( return createPortal(
<group position={[0, (ceiling.height ?? 2.5) + BRACKET_Y_OFFSET, 0]}> <group position={[0, (ceiling.height ?? 2.5) + BRACKET_Y_OFFSET, 0]}>
{corners.map((corner, index) => ( {corners.map((corner, index) => (
<CornerBracket <CornerBracket ceiling={ceiling} corner={corner} key={`${ceiling.id}-corner-${index}`} />
ceiling={ceiling}
corner={corner}
key={`${ceiling.id}-corner-${index}`}
/>
))} ))}
</group>, </group>,
levelObject, levelObject,
@@ -210,11 +208,7 @@ const BracketLeg = ({
] ]
return ( return (
<mesh <mesh onClick={onClick} position={position} rotation={[0, angle, 0]}>
onClick={onClick}
position={position}
rotation={[0, angle, 0]}
>
<boxGeometry args={[length, BRACKET_HEIGHT, BRACKET_THICKNESS]} /> <boxGeometry args={[length, BRACKET_HEIGHT, BRACKET_THICKNESS]} />
<meshBasicMaterial color={color} depthWrite={false} opacity={opacity} transparent /> <meshBasicMaterial color={color} depthWrite={false} opacity={opacity} transparent />
</mesh> </mesh>
@@ -234,7 +228,11 @@ function buildCornerBrackets(polygon: Array<[number, number]>): CornerBracketDat
const incomingLength = Math.hypot(incomingVector[0], incomingVector[1]) const incomingLength = Math.hypot(incomingVector[0], incomingVector[1])
const outgoingLength = Math.hypot(outgoingVector[0], outgoingVector[1]) const outgoingLength = Math.hypot(outgoingVector[0], outgoingVector[1])
const cornerStrength = 1 - Math.abs(incomingDirection[0] * outgoingDirection[0] + incomingDirection[1] * outgoingDirection[1]) const cornerStrength =
1 -
Math.abs(
incomingDirection[0] * outgoingDirection[0] + incomingDirection[1] * outgoingDirection[1],
)
return { return {
corner, corner,
@@ -6,7 +6,7 @@ import { useEffect, useRef } from 'react'
* Imperatively toggles the Three.js visibility of roof objects based on the * Imperatively toggles the Three.js visibility of roof objects based on the
* editor selection — without causing React re-renders in RoofRenderer. * editor selection — without causing React re-renders in RoofRenderer.
* *
* When a roof-segment is selected: * When a roof (or one of its segments) is selected:
* - merged-roof mesh is hidden * - merged-roof mesh is hidden
* - segments-wrapper group is shown (individual segments visible for editing) * - segments-wrapper group is shown (individual segments visible for editing)
* - all children are marked dirty so RoofSystem rebuilds their geometry * - all children are marked dirty so RoofSystem rebuilds their geometry
@@ -68,7 +68,7 @@ export const StairEditSystem = () => {
const segmentsWrapper = group.getObjectByName('segments-wrapper') const segmentsWrapper = group.getObjectByName('segments-wrapper')
const isActive = activeStairIds.has(stairId) const isActive = activeStairIds.has(stairId)
if (mergedMesh) mergedMesh.visible = !isActive && !isCurved if (mergedMesh) mergedMesh.visible = !(isActive || isCurved)
if (segmentsWrapper) segmentsWrapper.visible = isActive && !isCurved if (segmentsWrapper) segmentsWrapper.visible = isActive && !isCurved
if (stairNode?.children?.length) { if (stairNode?.children?.length) {
View File
@@ -1,13 +1,19 @@
'use client' 'use client'
import { type AnyNodeId, emitter, type GridEvent, useScene, type CeilingNode } from '@pascal-app/core' import {
type AnyNodeId,
type CeilingNode,
emitter,
type GridEvent,
useScene,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { BufferGeometry, DoubleSide, Path, Shape, ShapeGeometry, Vector3 } from 'three'
import { markToolCancelConsumed } from '../../../hooks/use-keyboard' import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
import { sfxEmitter } from '../../../lib/sfx-bus' import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor' import useEditor from '../../../store/use-editor'
import { CursorSphere } from '../shared/cursor-sphere' import { CursorSphere } from '../shared/cursor-sphere'
import { BufferGeometry, DoubleSide, Path, Shape, ShapeGeometry, Vector3 } from 'three'
function snap(value: number) { function snap(value: number) {
return Math.round(value * 2) / 2 return Math.round(value * 2) / 2
@@ -202,6 +208,7 @@ export const MoveCeilingTool: React.FC<{ node: CeilingNode }> = ({ node }) => {
transparent transparent
/> />
</mesh> </mesh>
{/* @ts-ignore */}
<line geometry={previewOutlineGeometry} position={[0, (node.height ?? 2.5) + 0.02, 0]}> <line geometry={previewOutlineGeometry} position={[0, (node.height ?? 2.5) + 0.02, 0]}>
<lineBasicMaterial color="#ffffff" depthWrite={false} opacity={0.95} transparent /> <lineBasicMaterial color="#ffffff" depthWrite={false} opacity={0.95} transparent />
</line> </line>
@@ -83,11 +83,10 @@ export const CurveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
? event.localPosition[2] ? event.localPosition[2]
: snapScalarToGrid(event.localPosition[2], snapStep) : snapScalarToGrid(event.localPosition[2], snapStep)
const offsetFromMidpoint = const offsetFromMidpoint = -(
-( (localX - chord.midpoint.x) * chord.normal.x +
(localX - chord.midpoint.x) * chord.normal.x + (localZ - chord.midpoint.y) * chord.normal.y
(localZ - chord.midpoint.y) * chord.normal.y )
)
const snappedOffset = shiftPressedRef.current const snappedOffset = shiftPressedRef.current
? offsetFromMidpoint ? offsetFromMidpoint
: snapScalarToGrid(offsetFromMidpoint, snapStep) : snapScalarToGrid(offsetFromMidpoint, snapStep)
@@ -297,7 +297,7 @@ export const FenceTool: React.FC = () => {
return ( return (
<group> <group>
<CursorSphere ref={cursorRef} height={FENCE_PREVIEW_HEIGHT} /> <CursorSphere height={FENCE_PREVIEW_HEIGHT} ref={cursorRef} />
<mesh layers={EDITOR_LAYER} ref={previewRef} renderOrder={1} visible={false}> <mesh layers={EDITOR_LAYER} ref={previewRef} renderOrder={1} visible={false}>
<shapeGeometry /> <shapeGeometry />
<meshBasicMaterial <meshBasicMaterial
@@ -338,7 +338,7 @@ function DraftMeasurementLabel({
}) { }) {
return ( return (
<Html center position={position} style={{ pointerEvents: 'none' }} zIndexRange={[100, 0]}> <Html center position={position} style={{ pointerEvents: 'none' }} zIndexRange={[100, 0]}>
<div className="whitespace-nowrap rounded-full border border-border bg-background/95 px-2 py-1 font-mono text-[11px] font-semibold text-foreground shadow-lg backdrop-blur-md"> <div className="whitespace-nowrap rounded-full border border-border bg-background/95 px-2 py-1 font-mono font-semibold text-[11px] text-foreground shadow-lg backdrop-blur-md">
{label} {label}
</div> </div>
</Html> </Html>
@@ -291,8 +291,9 @@ export const MoveFenceEndpointTool: React.FC<{ target: MovingFenceEndpoint }> =
} }
const preview = previewRef.current ?? { start: originalStart, end: originalEnd } const preview = previewRef.current ?? { start: originalStart, end: originalEnd }
const hasChanged = const hasChanged = !(
!samePoint(preview.start, originalStart) || !samePoint(preview.end, originalEnd) samePoint(preview.start, originalStart) && samePoint(preview.end, originalEnd)
)
if (hasChanged && isWallLongEnough(preview.start, preview.end)) { if (hasChanged && isWallLongEnough(preview.start, preview.end)) {
wasCommitted = true wasCommitted = true
@@ -392,7 +393,7 @@ export const MoveFenceEndpointTool: React.FC<{ target: MovingFenceEndpoint }> =
> >
<div className="translate-y-10"> <div className="translate-y-10">
<div <div
className={`whitespace-nowrap rounded-full border px-2 py-1 text-[11px] font-medium shadow-lg backdrop-blur-md transition-colors ${ className={`whitespace-nowrap rounded-full border px-2 py-1 font-medium text-[11px] shadow-lg backdrop-blur-md transition-colors ${
altPressed altPressed
? 'border-amber-500/70 bg-amber-500/15 text-amber-100' ? 'border-amber-500/70 bg-amber-500/15 text-amber-100'
: 'border-border/70 bg-background/90 text-foreground/80' : 'border-border/70 bg-background/90 text-foreground/80'
@@ -416,7 +417,7 @@ function EndpointAngleLabel({
}) { }) {
return ( return (
<Html center position={position} style={{ pointerEvents: 'none' }} zIndexRange={[100, 0]}> <Html center position={position} style={{ pointerEvents: 'none' }} zIndexRange={[100, 0]}>
<div className="whitespace-nowrap rounded-full border border-border bg-background/95 px-2 py-1 font-mono text-[11px] font-semibold text-foreground shadow-lg backdrop-blur-md"> <div className="whitespace-nowrap rounded-full border border-border bg-background/95 px-2 py-1 font-mono font-semibold text-[11px] text-foreground shadow-lg backdrop-blur-md">
{label} {label}
</div> </div>
</Html> </Html>
@@ -17,8 +17,8 @@ import type * as THREE from 'three'
import { markToolCancelConsumed } from '../../../hooks/use-keyboard' import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
import { sfxEmitter } from '../../../lib/sfx-bus' import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor' import useEditor from '../../../store/use-editor'
import { snapFenceDraftPoint } from './fence-drafting'
import { CursorSphere } from '../shared/cursor-sphere' import { CursorSphere } from '../shared/cursor-sphere'
import { snapFenceDraftPoint } from './fence-drafting'
function samePoint(a: [number, number], b: [number, number]) { function samePoint(a: [number, number], b: [number, number]) {
return a[0] === b[0] && a[1] === b[1] return a[0] === b[0] && a[1] === b[1]
@@ -50,10 +50,12 @@ function getLinkedFenceSnapshots(args: {
} }
if ( if (
!samePoint(node.start, originalStart) && !(
!samePoint(node.start, originalEnd) && samePoint(node.start, originalStart) ||
!samePoint(node.end, originalStart) && samePoint(node.start, originalEnd) ||
!samePoint(node.end, originalEnd) samePoint(node.end, originalStart) ||
samePoint(node.end, originalEnd)
)
) { ) {
continue continue
} }
@@ -164,7 +166,9 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
} }
} }
const applyNodePreview = (updates: Array<{ id: FenceNode['id']; start: [number, number]; end: [number, number] }>) => { const applyNodePreview = (
updates: Array<{ id: FenceNode['id']; start: [number, number]; end: [number, number] }>,
) => {
useScene.getState().updateNodes( useScene.getState().updateNodes(
updates.map((entry) => ({ updates.map((entry) => ({
id: entry.id as AnyNodeId, id: entry.id as AnyNodeId,
@@ -275,13 +279,13 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
emitter.on('tool:cancel', onCancel) emitter.on('tool:cancel', onCancel)
return () => { return () => {
if (!wasCommitted) { if (wasCommitted) {
clearPreviewState()
} else {
useLiveTransforms.getState().clear(nodeId) useLiveTransforms.getState().clear(nodeId)
for (const linkedFence of linkedOriginalsRef.current) { for (const linkedFence of linkedOriginalsRef.current) {
useLiveTransforms.getState().clear(linkedFence.id) useLiveTransforms.getState().clear(linkedFence.id)
} }
} else {
clearPreviewState()
} }
useScene.temporal.getState().resume() useScene.temporal.getState().resume()
emitter.off('grid:move', onGridMove) emitter.off('grid:move', onGridMove)
@@ -90,9 +90,7 @@ function MoveItemContent({ movingNode }: { movingNode: ItemNode }) {
return <>{cursor}</> return <>{cursor}</>
} }
export const MoveTool: React.FC<{ export const MoveTool: React.FC = () => {
onSpawnMoved?: (nodeId: SpawnNode['id']) => void
}> = ({ onSpawnMoved }) => {
const movingNode = useEditor((state) => state.movingNode) const movingNode = useEditor((state) => state.movingNode)
if (!movingNode) return null if (!movingNode) return null
@@ -100,15 +98,14 @@ export const MoveTool: React.FC<{
return <MoveBuildingContent node={movingNode as BuildingNode} /> return <MoveBuildingContent node={movingNode as BuildingNode} />
if (movingNode.type === 'door') return <MoveDoorTool node={movingNode as DoorNode} /> if (movingNode.type === 'door') return <MoveDoorTool node={movingNode as DoorNode} />
if (movingNode.type === 'window') return <MoveWindowTool node={movingNode as WindowNode} /> if (movingNode.type === 'window') return <MoveWindowTool node={movingNode as WindowNode} />
if (movingNode.type === 'fence') return <MoveFenceTool node={movingNode as FenceNode} />
if (movingNode.type === 'ceiling') return <MoveCeilingTool node={movingNode as CeilingNode} /> if (movingNode.type === 'ceiling') return <MoveCeilingTool node={movingNode as CeilingNode} />
if (movingNode.type === 'column') return <MoveColumnTool node={movingNode as ColumnNode} /> if (movingNode.type === 'column') return <MoveColumnTool node={movingNode as ColumnNode} />
if (movingNode.type === 'slab') return <MoveSlabTool node={movingNode as SlabNode} /> if (movingNode.type === 'slab') return <MoveSlabTool node={movingNode as SlabNode} />
if (movingNode.type === 'wall') return <MoveWallTool node={movingNode as WallNode} /> if (movingNode.type === 'wall') return <MoveWallTool node={movingNode as WallNode} />
if (movingNode.type === 'fence') return <MoveFenceTool node={movingNode as FenceNode} />
if (movingNode.type === 'roof' || movingNode.type === 'roof-segment') if (movingNode.type === 'roof' || movingNode.type === 'roof-segment')
return <MoveRoofTool node={movingNode as RoofNode | RoofSegmentNode} /> return <MoveRoofTool node={movingNode as RoofNode | RoofSegmentNode} />
if (movingNode.type === 'spawn') if (movingNode.type === 'spawn') return <MoveSpawnTool node={movingNode as SpawnNode} />
return <MoveSpawnTool node={movingNode as SpawnNode} onCommitted={onSpawnMoved} />
if (movingNode.type === 'stair' || movingNode.type === 'stair-segment') if (movingNode.type === 'stair' || movingNode.type === 'stair-segment')
return <MoveRoofTool node={movingNode as StairNode | StairSegmentNode} /> return <MoveRoofTool node={movingNode as StairNode | StairSegmentNode} />
return <MoveItemContent movingNode={movingNode as ItemNode} /> return <MoveItemContent movingNode={movingNode as ItemNode} />
@@ -10,9 +10,7 @@ function positiveModulo(value: number, divisor: number): number {
} }
/** /**
* Snaps a position to 0.5 grid, with an offset to align item edges to grid lines. * Snaps a position to the active grid step, aligning item edges to grid lines.
* For items with dimensions like 2.5, the center would be at 1.25 from the edge,
* which doesn't align with 0.5 grid. This adds an offset so edges align instead.
*/ */
export function snapToGrid(position: number, dimension: number, step = getGridSnapStep()): number { export function snapToGrid(position: number, dimension: number, step = getGridSnapStep()): number {
const halfDim = dimension / 2 const halfDim = dimension / 2
@@ -21,7 +19,7 @@ export function snapToGrid(position: number, dimension: number, step = getGridSn
} }
/** /**
* Snap a value to 0.5 increments (used for wall-local positions). * Snap a value to the active grid step (used for wall-local positions).
*/ */
export function snapToHalf(value: number, step = getGridSnapStep()): number { export function snapToHalf(value: number, step = getGridSnapStep()): number {
return Math.round(value / step) * step return Math.round(value / step) * step
@@ -506,21 +506,22 @@ export const itemSurfaceStrategy = {
const worldSnapped = surfaceMesh.localToWorld(new Vector3(x, y, z)) const worldSnapped = surfaceMesh.localToWorld(new Vector3(x, y, z))
// Counter-rotate so the draft's world Y rotation stays continuous when
// the user drags onto a rotated surface item. The cursor wireframe
// already shows the user's intended world rotation; we just need to
// store the right local value relative to the new parent.
const surfaceQuat = new Quaternion()
surfaceMesh.getWorldQuaternion(surfaceQuat)
const surfaceWorldY = new Euler().setFromQuaternion(surfaceQuat, 'YXZ').y
const localRotationY = ctx.currentCursorRotationY - surfaceWorldY
const draftRotation = ctx.draftItem?.rotation ?? [0, 0, 0]
return { return {
stateUpdate: { surface: 'item-surface', surfaceItemId: surfaceItem.id }, stateUpdate: { surface: 'item-surface', surfaceItemId: surfaceItem.id },
nodeUpdate: { nodeUpdate: {
position: [x, y, z], position: [x, y, z],
parentId: surfaceItem.id, parentId: surfaceItem.id,
rotation: [ rotation: [draftRotation[0], localRotationY, draftRotation[2]],
(ctx.draftItem?.rotation ?? [0, 0, 0])[0],
(() => {
const surfaceQuat = new Quaternion()
surfaceMesh.getWorldQuaternion(surfaceQuat)
const surfaceWorldY = new Euler().setFromQuaternion(surfaceQuat, 'YXZ').y
return ctx.currentCursorRotationY - surfaceWorldY
})(),
(ctx.draftItem?.rotation ?? [0, 0, 0])[2],
] as [number, number, number],
}, },
cursorRotationY: ctx.currentCursorRotationY, cursorRotationY: ctx.currentCursorRotationY,
gridPosition: [x, y, z], gridPosition: [x, y, z],
@@ -130,7 +130,6 @@ export function useDraftNode(): DraftNodeHandle {
useScene.getState().updateNode(draft.id, { useScene.getState().updateNode(draft.id, {
position: updateProps.position ?? draft.position, position: updateProps.position ?? draft.position,
rotation: updateProps.rotation ?? draft.rotation, rotation: updateProps.rotation ?? draft.rotation,
scale: updateProps.scale ?? draft.scale,
side: updateProps.side ?? draft.side, side: updateProps.side ?? draft.side,
metadata: updateProps.metadata ?? stripTransient(draft.metadata), metadata: updateProps.metadata ?? stripTransient(draft.metadata),
parentId: parentId as string, parentId: parentId as string,
@@ -20,15 +20,12 @@ import { Html } from '@react-three/drei'
import { useFrame } from '@react-three/fiber' import { useFrame } from '@react-three/fiber'
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { import {
Box3,
BufferGeometry, BufferGeometry,
Euler, Euler,
Float32BufferAttribute, Float32BufferAttribute,
type Group, type Group,
type LineSegments, type LineSegments,
Matrix4,
type Mesh, type Mesh,
type Object3D,
PlaneGeometry, PlaneGeometry,
Quaternion, Quaternion,
Vector3, Vector3,
@@ -117,79 +114,6 @@ function expandBoundsToGrid(
} }
} }
function getPreviewBoundsFromObject(object: Object3D | null): PreviewBounds | null {
if (!object) return null
object.updateWorldMatrix(true, true)
const inverseRootMatrix = new Matrix4().copy(object.matrixWorld).invert()
const localMatrix = new Matrix4()
const localBounds = new Box3()
const scratchBounds = new Box3()
const hasBounds = { current: false }
const registeredNodeObjects = new Set(sceneRegistry.nodes.values())
const expandBounds = (child: Object3D) => {
if (child !== object && registeredNodeObjects.has(child)) {
return
}
const mesh = child as Object3D & {
isMesh?: boolean
name?: string
geometry?: {
boundingBox: Box3 | null
computeBoundingBox?: () => void
}
}
if (mesh.isMesh && mesh.name !== 'cutout' && mesh.geometry) {
if (!mesh.geometry.boundingBox && mesh.geometry.computeBoundingBox) {
mesh.geometry.computeBoundingBox()
}
if (mesh.geometry.boundingBox) {
localMatrix.copy(inverseRootMatrix).multiply(mesh.matrixWorld)
scratchBounds.copy(mesh.geometry.boundingBox).applyMatrix4(localMatrix)
if (Number.isFinite(scratchBounds.min.x) && Number.isFinite(scratchBounds.max.x)) {
if (!hasBounds.current) {
localBounds.copy(scratchBounds)
hasBounds.current = true
} else {
localBounds.union(scratchBounds)
}
}
}
}
for (const grandchild of child.children) {
expandBounds(grandchild)
}
}
for (const child of object.children) {
expandBounds(child)
}
if (!hasBounds.current) return null
const size = new Vector3()
const center = new Vector3()
localBounds.getSize(size)
localBounds.getCenter(center)
if (size.x <= 0 || size.y <= 0 || size.z <= 0) {
return null
}
return {
min: [localBounds.min.x, localBounds.min.y, localBounds.min.z],
max: [localBounds.max.x, localBounds.max.y, localBounds.max.z],
dimensions: [size.x, size.y, size.z],
center: [center.x, center.y, center.z],
}
}
function getFallbackPreviewBounds( function getFallbackPreviewBounds(
item: import('@pascal-app/core').ItemNode | null, item: import('@pascal-app/core').ItemNode | null,
asset: AssetInput, asset: AssetInput,
@@ -366,7 +290,6 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
) )
const shiftFreeRef = useRef(false) const shiftFreeRef = useRef(false)
const previewBoundsSignatureRef = useRef<string | null>(null) const previewBoundsSignatureRef = useRef<string | null>(null)
const meshPreviewAppliedRef = useRef(false)
const [dimensionBounds, setDimensionBounds] = useState<PreviewBounds | null>(null) const [dimensionBounds, setDimensionBounds] = useState<PreviewBounds | null>(null)
// Store config callbacks in refs to avoid re-running effect when they change // Store config callbacks in refs to avoid re-running effect when they change
@@ -503,7 +426,6 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
useEffect(() => { useEffect(() => {
if (!asset) return if (!asset) return
useScene.temporal.getState().pause() useScene.temporal.getState().pause()
meshPreviewAppliedRef.current = false
const validators = { canPlaceOnFloor, canPlaceOnWall, canPlaceOnCeiling } const validators = { canPlaceOnFloor, canPlaceOnWall, canPlaceOnCeiling }
@@ -1264,21 +1186,17 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
window.addEventListener('contextmenu', onContextMenu) window.addEventListener('contextmenu', onContextMenu)
// ---- Bounding box geometry ---- // ---- Bounding box geometry ----
// Always derive the wireframe from `asset.dimensions × scale` rather than
// the rendered mesh bounds. Asset dimensions describe the item's footprint
// (e.g. only the trunk for a palm tree), while the mesh bbox would include
// foliage or other visual overhang the snap logic intentionally ignores.
const draft = draftNode.current const draft = draftNode.current
const fallbackBounds = expandBoundsToGrid( const previewBounds = expandBoundsToGrid(
getFallbackPreviewBounds(draft, asset, asset.attachTo), getFallbackPreviewBounds(draft, asset, asset.attachTo),
asset.attachTo, asset.attachTo,
gridSnapStep, gridSnapStep,
) )
const previewBounds = draft
? expandBoundsToGrid(
getPreviewBoundsFromObject(sceneRegistry.nodes.get(draft.id) ?? null) ??
getFallbackPreviewBounds(draft, asset, asset.attachTo),
asset.attachTo,
gridSnapStep,
)
: fallbackBounds
updatePreviewGeometry(previewBounds) updatePreviewGeometry(previewBounds)
updateDimensionGuides(previewBounds) updateDimensionGuides(previewBounds)
@@ -1324,7 +1242,6 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
return () => { return () => {
tearingDown = true tearingDown = true
meshPreviewAppliedRef.current = false
unsubDraftWatch() unsubDraftWatch()
// Clear live transform for any remaining draft // Clear live transform for any remaining draft
if (draftNode.current) { if (draftNode.current) {
@@ -1358,17 +1275,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
useEffect(() => { useEffect(() => {
if (!asset) return if (!asset) return
const draft = draftNode.current const draft = draftNode.current
const fallbackBounds = expandBoundsToGrid( const previewBounds = expandBoundsToGrid(
getFallbackPreviewBounds(draft, asset, asset.attachTo), getFallbackPreviewBounds(draft, asset, asset.attachTo),
asset.attachTo, asset.attachTo,
gridSnapStep, gridSnapStep,
) )
const meshBounds = draft
? getPreviewBoundsFromObject(sceneRegistry.nodes.get(draft.id) ?? null)
: null
const previewBounds = meshBounds
? expandBoundsToGrid(meshBounds, asset.attachTo, gridSnapStep)
: fallbackBounds
updatePreviewGeometry(previewBounds) updatePreviewGeometry(previewBounds)
updateDimensionGuides(previewBounds) updateDimensionGuides(previewBounds)
}, [gridSnapStep, asset, draftNode]) }, [gridSnapStep, asset, draftNode])
@@ -1388,19 +1299,6 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
if (!draftNode.current) return if (!draftNode.current) return
const mesh = sceneRegistry.nodes.get(draftNode.current.id) const mesh = sceneRegistry.nodes.get(draftNode.current.id)
if (!mesh) return if (!mesh) return
if (!meshPreviewAppliedRef.current) {
const previewBounds = getPreviewBoundsFromObject(mesh)
if (previewBounds) {
const expandedBounds = expandBoundsToGrid(
previewBounds,
asset.attachTo,
useEditor.getState().gridSnapStep,
)
updatePreviewGeometry(expandedBounds)
updateDimensionGuides(expandedBounds)
meshPreviewAppliedRef.current = true
}
}
// Hide wall/ceiling-attached items when between surfaces (only cursor visible) // Hide wall/ceiling-attached items when between surfaces (only cursor visible)
if (asset.attachTo && placementState.current.surface === 'floor') { if (asset.attachTo && placementState.current.surface === 'floor') {
@@ -1490,22 +1388,22 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
const measurementContent = ( const measurementContent = (
<> <>
<lineSegments <lineSegments
layers={EDITOR_LAYER}
geometry={initialWidthGuideGeometry} geometry={initialWidthGuideGeometry}
layers={EDITOR_LAYER}
material={measurementMaterial} material={measurementMaterial}
ref={measurementWidthRef} ref={measurementWidthRef}
renderOrder={998} renderOrder={998}
/> />
<lineSegments <lineSegments
layers={EDITOR_LAYER}
geometry={initialDepthGuideGeometry} geometry={initialDepthGuideGeometry}
layers={EDITOR_LAYER}
material={measurementMaterial} material={measurementMaterial}
ref={measurementDepthRef} ref={measurementDepthRef}
renderOrder={998} renderOrder={998}
/> />
<lineSegments <lineSegments
layers={EDITOR_LAYER}
geometry={initialHeightGuideGeometry} geometry={initialHeightGuideGeometry}
layers={EDITOR_LAYER}
material={measurementMaterial} material={measurementMaterial}
ref={measurementHeightRef} ref={measurementHeightRef}
renderOrder={998} renderOrder={998}
@@ -158,7 +158,9 @@ export const MoveRoofTool: React.FC<{
const localToWorldPoint = (localPoint: WallPlanPoint, y: number): [number, number, number] => { const localToWorldPoint = (localPoint: WallPlanPoint, y: number): [number, number, number] => {
if (buildingObj) { if (buildingObj) {
const worldPoint = buildingObj.localToWorld(new THREE.Vector3(localPoint[0], y, localPoint[1])) const worldPoint = buildingObj.localToWorld(
new THREE.Vector3(localPoint[0], y, localPoint[1]),
)
return [worldPoint.x, worldPoint.y, worldPoint.z] return [worldPoint.x, worldPoint.y, worldPoint.z]
} }
@@ -211,7 +213,10 @@ export const MoveRoofTool: React.FC<{
}) })
const [gridX, , gridZ] = localToWorldPoint(snappedLocal, y) const [gridX, , gridZ] = localToWorldPoint(snappedLocal, y)
if (previousGridPosRef.current && (gridX !== previousGridPosRef.current[0] || gridZ !== previousGridPosRef.current[1])) { if (
previousGridPosRef.current &&
(gridX !== previousGridPosRef.current[0] || gridZ !== previousGridPosRef.current[1])
) {
sfxEmitter.emit('sfx:grid-snap') sfxEmitter.emit('sfx:grid-snap')
} }
@@ -16,8 +16,8 @@ import {
type ZoneNode, type ZoneNode,
} from '@pascal-app/core' } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { useThree } from '@react-three/fiber'
import type { ThreeElements } from '@react-three/fiber' import type { ThreeElements } from '@react-three/fiber'
import { useThree } from '@react-three/fiber'
import { useEffect, useRef } from 'react' import { useEffect, useRef } from 'react'
import { import {
Box3, Box3,
@@ -202,7 +202,17 @@ function collectNodeIdsInBounds(bounds: Bounds): string[] {
const result: string[] = [] const result: string[] = []
if (phase === 'structure' && structureLayer === 'elements') { if (phase === 'structure' && structureLayer === 'zones') {
for (const childId of levelNode.children) {
const node = nodes[childId as AnyNodeId]
if (!node || node.type !== 'zone') continue
const zone = node as ZoneNode
if (polygonIntersectsBounds(zone.polygon, bounds)) {
result.push(zone.id)
}
}
} else {
// structure (elements) and furnish: collect all node types
for (const childId of levelNode.children) { for (const childId of levelNode.children) {
const node = nodes[childId as AnyNodeId] const node = nodes[childId as AnyNodeId]
if (!node) continue if (!node) continue
@@ -255,22 +265,7 @@ function collectNodeIdsInBounds(bounds: Bounds): string[] {
if (objectBoundsIntersectsBounds(column.id, bounds)) { if (objectBoundsIntersectsBounds(column.id, bounds)) {
result.push(column.id) result.push(column.id)
} }
} } else if (node.type === 'item') {
}
} else if (phase === 'structure' && structureLayer === 'zones') {
for (const childId of levelNode.children) {
const node = nodes[childId as AnyNodeId]
if (!node || node.type !== 'zone') continue
const zone = node as ZoneNode
if (polygonIntersectsBounds(zone.polygon, bounds)) {
result.push(zone.id)
}
}
} else if (phase === 'furnish') {
for (const childId of levelNode.children) {
const node = nodes[childId as AnyNodeId]
if (!node) continue
if (node.type === 'item') {
const item = node as ItemNode const item = node as ItemNode
if (item.asset.category === 'door' || item.asset.category === 'window') continue if (item.asset.category === 'door' || item.asset.category === 'window') continue
const xz = getNodeWorldXZ(item.id) const xz = getNodeWorldXZ(item.id)

Some files were not shown because too many files have changed in this diff Show More