Merge branch 'main' into feat/elevator-system
# Conflicts: # packages/editor/src/components/tools/item/move-tool.tsx # packages/editor/src/components/tools/tool-manager.tsx # packages/editor/src/components/ui/panels/panel-manager.tsx # packages/editor/src/store/use-editor.tsx # packages/viewer/src/components/renderers/site/site-renderer.tsx # packages/viewer/src/components/viewer/ground-occluder.tsx # packages/viewer/src/components/viewer/index.tsx # packages/viewer/src/components/viewer/post-processing.tsx
This commit is contained in:
@@ -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
|
||||
+13
-13
@@ -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:
|
||||
|
||||
- `.claude/rules/systems.md` — core systems vs viewer systems, what each may do
|
||||
- `.claude/rules/renderers.md` — renderer responsibilities and prohibitions
|
||||
- `.claude/rules/tools.md` — editor tools live only in `apps/editor/components/tools/`
|
||||
- `.claude/rules/viewer-isolation.md` — viewer must stay editor-agnostic
|
||||
- `.claude/rules/layers.md`
|
||||
- `.claude/rules/selection-managers.md`
|
||||
- `.claude/rules/scene-registry.md`
|
||||
- `.claude/rules/spatial-queries.md`
|
||||
- `.claude/rules/node-schemas.md`
|
||||
- `.claude/rules/events.md`
|
||||
- `wiki/architecture/systems.md` — core systems vs viewer systems, what each may do
|
||||
- `wiki/architecture/renderers.md` — renderer responsibilities and prohibitions
|
||||
- `wiki/architecture/tools.md` — editor tools live only in `apps/editor/components/tools/`
|
||||
- `wiki/architecture/viewer-isolation.md` — viewer must stay editor-agnostic
|
||||
- `wiki/architecture/layers.md`
|
||||
- `wiki/architecture/selection-managers.md`
|
||||
- `wiki/architecture/scene-registry.md`
|
||||
- `wiki/architecture/spatial-queries.md`
|
||||
- `wiki/architecture/node-schemas.md`
|
||||
- `wiki/architecture/events.md`
|
||||
|
||||
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 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 `.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`)
|
||||
- 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:
|
||||
|
||||
- **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.
|
||||
- **Nit** — minor, optional.
|
||||
|
||||
@@ -103,7 +103,7 @@ For each finding, include:
|
||||
|
||||
1. File and line: `path/to/file.ts:42`
|
||||
2. The offending snippet (short — 1–5 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
|
||||
|
||||
Skip formatting, import ordering, and anything CI already covers.
|
||||
@@ -1 +0,0 @@
|
||||
../../.cursor/rules/creating-rules.mdc
|
||||
@@ -1 +0,0 @@
|
||||
../../.cursor/rules/events.mdc
|
||||
@@ -1 +0,0 @@
|
||||
../../.cursor/rules/layers.mdc
|
||||
@@ -1 +0,0 @@
|
||||
../../.cursor/rules/node-schemas.mdc
|
||||
@@ -1 +0,0 @@
|
||||
../../.cursor/rules/renderers.mdc
|
||||
@@ -1 +0,0 @@
|
||||
../../.cursor/rules/scene-registry.mdc
|
||||
@@ -1 +0,0 @@
|
||||
../../.cursor/rules/selection-managers.mdc
|
||||
@@ -1 +0,0 @@
|
||||
../../.cursor/rules/spatial-queries.mdc
|
||||
@@ -1 +0,0 @@
|
||||
../../.cursor/rules/systems.mdc
|
||||
@@ -1 +0,0 @@
|
||||
../../.cursor/rules/tools.mdc
|
||||
@@ -1 +0,0 @@
|
||||
../../.cursor/rules/viewer-isolation.mdc
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../.agents/skills
|
||||
@@ -1 +0,0 @@
|
||||
../../.cursor/rules/creating-rules.mdc
|
||||
@@ -1 +0,0 @@
|
||||
../../.cursor/rules/events.mdc
|
||||
@@ -1 +0,0 @@
|
||||
../../.cursor/rules/layers.mdc
|
||||
@@ -1 +0,0 @@
|
||||
../../.cursor/rules/node-schemas.mdc
|
||||
@@ -1 +0,0 @@
|
||||
../../.cursor/rules/renderers.mdc
|
||||
@@ -1 +0,0 @@
|
||||
../../.cursor/rules/scene-registry.mdc
|
||||
@@ -1 +0,0 @@
|
||||
../../.cursor/rules/selection-managers.mdc
|
||||
@@ -1 +0,0 @@
|
||||
../../.cursor/rules/spatial-queries.mdc
|
||||
@@ -1 +0,0 @@
|
||||
../../.cursor/rules/systems.mdc
|
||||
@@ -1 +0,0 @@
|
||||
../../.cursor/rules/tools.mdc
|
||||
@@ -1 +0,0 @@
|
||||
../../.cursor/rules/viewer-isolation.mdc
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../.agents/skills
|
||||
@@ -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 — 1–5 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
|
||||
@@ -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 |
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../.agents/skills
|
||||
@@ -1 +0,0 @@
|
||||
../../../.claude/skills/review-architecture/SKILL.md
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../AGENTS.md
|
||||
+100
-31
@@ -10,7 +10,9 @@ on:
|
||||
options:
|
||||
- core
|
||||
- viewer
|
||||
- both
|
||||
- editor
|
||||
- mcp
|
||||
- all
|
||||
bump:
|
||||
description: "Version bump"
|
||||
required: true
|
||||
@@ -51,56 +53,115 @@ jobs:
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
- name: Bump & publish core
|
||||
if: inputs.package == 'core' || inputs.package == 'both'
|
||||
- name: Bump versions and sync inter-package references
|
||||
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
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
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
|
||||
|
||||
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
|
||||
else
|
||||
npm publish --access public
|
||||
echo "📦 Published @pascal-app/core@$VERSION"
|
||||
echo "📦 Published @pascal-app/core@$CORE_VERSION"
|
||||
fi
|
||||
|
||||
- name: Bump & publish viewer
|
||||
if: inputs.package == 'viewer' || inputs.package == 'both'
|
||||
- name: Build & publish viewer
|
||||
if: inputs.package == 'viewer' || inputs.package == 'all'
|
||||
working-directory: packages/viewer
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
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
|
||||
|
||||
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
|
||||
else
|
||||
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
|
||||
|
||||
- name: Commit version bumps & tag
|
||||
@@ -118,6 +179,14 @@ jobs:
|
||||
PKGS="$PKGS @pascal-app/viewer@$VIEWER_VERSION"
|
||||
TAGS="$TAGS @pascal-app/viewer@$VIEWER_VERSION"
|
||||
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}"
|
||||
|
||||
|
||||
@@ -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`.
|
||||
Codex-compatible paths are exposed in `.codex/rules/*.md`.
|
||||
## Where to look
|
||||
|
||||
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
|
||||
- `.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`
|
||||
- **`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.
|
||||
- **`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.
|
||||
- **`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.
|
||||
|
||||
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.
|
||||
2. Lead with layer-boundary blockers.
|
||||
3. Check hook hygiene for `useEditor`, `useScene`, and `useViewer`.
|
||||
4. Check selector performance for broad subscriptions and selectors that allocate fresh references.
|
||||
5. Skip formatting and import ordering unless they hide a real behavior or architecture issue.
|
||||
|
||||
Use `.codex/skills/review-architecture/SKILL.md` when the user asks Codex to review a PR, audit a branch, or check architecture compliance.
|
||||
- Read the full file before editing. Plan all changes, then make one complete edit.
|
||||
- When the user corrects you, stop and re-read their message.
|
||||
- After two consecutive tool failures, stop and change approach.
|
||||
- Don't introduce backwards-compatibility shims, dead code, or speculative abstractions.
|
||||
- Don't write new comments unless they explain a non-obvious *why*.
|
||||
|
||||
@@ -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/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>
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -173,7 +173,7 @@ function parseIfMatch(raw: string | null): number | undefined {
|
||||
const inner = match ? match[1] : trimmed
|
||||
if (!inner) return undefined
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import "../../../styles/elevation.css";
|
||||
@source "../../../packages/editor/src";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@@ -1,13 +1,34 @@
|
||||
'use client'
|
||||
|
||||
import { Editor, type SidebarTab, ViewerToolbarLeft, ViewerToolbarRight } from '@pascal-app/editor'
|
||||
import { Editor, ItemsPanel } from '@pascal-app/editor'
|
||||
import { Layers, Package, Settings } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import {
|
||||
CommunityViewerToolbarLeft,
|
||||
CommunityViewerToolbarRight,
|
||||
} from '@/components/viewer-toolbar'
|
||||
|
||||
const SIDEBAR_TABS: (SidebarTab & { component: React.ComponentType })[] = [
|
||||
const SIDEBAR_TABS = [
|
||||
{
|
||||
id: 'site',
|
||||
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" />,
|
||||
},
|
||||
]
|
||||
|
||||
@@ -36,8 +57,8 @@ export default function Home() {
|
||||
layoutVersion="v2"
|
||||
projectId={PROJECT_ID}
|
||||
sidebarTabs={SIDEBAR_TABS}
|
||||
viewerToolbarLeft={<ViewerToolbarLeft />}
|
||||
viewerToolbarRight={<ViewerToolbarRight />}
|
||||
viewerToolbarLeft={<CommunityViewerToolbarLeft />}
|
||||
viewerToolbarRight={<CommunityViewerToolbarRight />}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -5,12 +5,11 @@ import {
|
||||
Editor,
|
||||
type SceneGraph,
|
||||
type SidebarTab,
|
||||
ViewerToolbarLeft,
|
||||
ViewerToolbarRight,
|
||||
} from '@pascal-app/editor'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { CommunityViewerToolbarLeft, CommunityViewerToolbarRight } from './viewer-toolbar'
|
||||
|
||||
export interface SceneMeta {
|
||||
id: string
|
||||
@@ -200,8 +199,8 @@ export function SceneLoader({ initialScene, meta }: SceneLoaderProps) {
|
||||
onThumbnailCapture={handleThumb}
|
||||
projectId={meta.projectId ?? 'default'}
|
||||
sidebarTabs={SIDEBAR_TABS}
|
||||
viewerToolbarLeft={<ViewerToolbarLeft />}
|
||||
viewerToolbarRight={<ViewerToolbarRight />}
|
||||
viewerToolbarLeft={<CommunityViewerToolbarLeft />}
|
||||
viewerToolbarRight={<CommunityViewerToolbarRight />}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
'use client'
|
||||
|
||||
import * as TooltipPrimitive from '@radix-ui/react-tooltip'
|
||||
import type * as React from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function TooltipProvider({
|
||||
delayDuration = 0,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
|
||||
return <TooltipPrimitive.Provider delayDuration={delayDuration} {...props} />
|
||||
}
|
||||
|
||||
function Tooltip({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<TooltipPrimitive.Root {...props} />
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function TooltipTrigger({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||
return <TooltipPrimitive.Trigger {...props} />
|
||||
}
|
||||
|
||||
function TooltipContent({
|
||||
className,
|
||||
sideOffset = 6,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
className={cn(
|
||||
'fade-in-0 zoom-in-95 data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) animate-in rounded-md bg-foreground px-3 py-1.5 text-background text-xs data-[state=closed]:animate-out',
|
||||
className,
|
||||
)}
|
||||
sideOffset={sideOffset}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground" />
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }
|
||||
@@ -0,0 +1,362 @@
|
||||
'use client'
|
||||
|
||||
import { Icon as IconifyIcon } from '@iconify/react'
|
||||
import { useEditor, useSidebarStore, type ViewMode } from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import {
|
||||
ChevronsLeft,
|
||||
ChevronsRight,
|
||||
Columns2,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Footprints,
|
||||
Grid2X2,
|
||||
Moon,
|
||||
Sun,
|
||||
} from 'lucide-react'
|
||||
import Image from 'next/image'
|
||||
import { type ReactNode, useCallback } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from './toolbar-tooltip'
|
||||
|
||||
const TOOLBAR_CONTAINER =
|
||||
'inline-flex h-8 items-stretch overflow-hidden rounded-xl border border-border bg-background/90 shadow-2xl backdrop-blur-md'
|
||||
|
||||
const TOOLBAR_BTN =
|
||||
'flex w-8 items-center justify-center text-muted-foreground/80 transition-colors hover:bg-white/8 hover:text-foreground/90'
|
||||
|
||||
function ToolbarTooltip({ children, label }: { children: ReactNode; label: string }) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{children}</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{label}</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
const VIEW_MODES: { id: ViewMode; label: string; icon: React.ReactNode }[] = [
|
||||
{
|
||||
id: '3d',
|
||||
label: '3D',
|
||||
icon: (
|
||||
<Image
|
||||
alt=""
|
||||
className="h-3.5 w-3.5 object-contain"
|
||||
height={14}
|
||||
src="/icons/building.png"
|
||||
width={14}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: '2d',
|
||||
label: '2D',
|
||||
icon: (
|
||||
<Image
|
||||
alt=""
|
||||
className="h-3.5 w-3.5 object-contain"
|
||||
height={14}
|
||||
src="/icons/blueprint.png"
|
||||
width={14}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'split',
|
||||
label: 'Split',
|
||||
icon: <Columns2 className="h-3 w-3" />,
|
||||
},
|
||||
]
|
||||
|
||||
const levelModeOrder = ['stacked', 'exploded', 'solo'] as const
|
||||
const levelModeLabels: Record<string, string> = {
|
||||
manual: 'Stack',
|
||||
stacked: 'Stack',
|
||||
exploded: 'Exploded',
|
||||
solo: 'Solo',
|
||||
}
|
||||
|
||||
const wallModeOrder = ['cutaway', 'up', 'down'] as const
|
||||
const wallModeConfig: Record<string, { icon: string; label: string }> = {
|
||||
up: { icon: '/icons/room.png', label: 'Full height' },
|
||||
cutaway: { icon: '/icons/wallcut.png', label: 'Cutaway' },
|
||||
down: { icon: '/icons/walllow.png', label: 'Low' },
|
||||
}
|
||||
|
||||
function ViewModeControl() {
|
||||
const viewMode = useEditor((state) => state.viewMode)
|
||||
const setViewMode = useEditor((state) => state.setViewMode)
|
||||
|
||||
return (
|
||||
<div className={TOOLBAR_CONTAINER}>
|
||||
{VIEW_MODES.map((mode) => {
|
||||
const isActive = viewMode === mode.id
|
||||
return (
|
||||
<ToolbarTooltip key={mode.id} label={mode.label}>
|
||||
<button
|
||||
aria-label={mode.label}
|
||||
aria-pressed={isActive}
|
||||
className={cn(
|
||||
'flex items-center justify-center gap-1.5 px-2.5 font-medium text-xs transition-colors',
|
||||
isActive
|
||||
? 'bg-white/10 text-foreground'
|
||||
: 'text-muted-foreground/70 hover:bg-white/8 hover:text-muted-foreground',
|
||||
)}
|
||||
onClick={() => setViewMode(mode.id)}
|
||||
type="button"
|
||||
>
|
||||
{mode.icon}
|
||||
<span>{mode.label}</span>
|
||||
</button>
|
||||
</ToolbarTooltip>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CollapseSidebarButton() {
|
||||
const isCollapsed = useSidebarStore((state) => state.isCollapsed)
|
||||
const setIsCollapsed = useSidebarStore((state) => state.setIsCollapsed)
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
setIsCollapsed(!isCollapsed)
|
||||
}, [isCollapsed, setIsCollapsed])
|
||||
|
||||
return (
|
||||
<div className={TOOLBAR_CONTAINER}>
|
||||
<ToolbarTooltip label={isCollapsed ? 'Expand sidebar' : 'Collapse sidebar'}>
|
||||
<button
|
||||
aria-label={isCollapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
||||
className={TOOLBAR_BTN}
|
||||
onClick={toggle}
|
||||
type="button"
|
||||
>
|
||||
{isCollapsed ? (
|
||||
<ChevronsRight className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronsLeft className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</ToolbarTooltip>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LevelModeToggle() {
|
||||
const levelMode = useViewer((state) => state.levelMode)
|
||||
const setLevelMode = useViewer((state) => state.setLevelMode)
|
||||
const isDefault = levelMode === 'stacked' || levelMode === 'manual'
|
||||
|
||||
const cycle = () => {
|
||||
if (levelMode === 'manual') {
|
||||
setLevelMode('stacked')
|
||||
return
|
||||
}
|
||||
|
||||
const index = levelModeOrder.indexOf(levelMode as (typeof levelModeOrder)[number])
|
||||
const next = levelModeOrder[(index + 1) % levelModeOrder.length]
|
||||
if (next) setLevelMode(next)
|
||||
}
|
||||
|
||||
const label = `Levels: ${levelMode === 'manual' ? 'Manual' : (levelModeLabels[levelMode] ?? 'Stack')}`
|
||||
|
||||
return (
|
||||
<ToolbarTooltip label={label}>
|
||||
<button
|
||||
className={cn(
|
||||
TOOLBAR_BTN,
|
||||
'w-auto gap-1.5 px-2.5',
|
||||
!isDefault && 'bg-white/10 text-foreground/90',
|
||||
)}
|
||||
onClick={cycle}
|
||||
type="button"
|
||||
>
|
||||
{levelMode === 'solo' ? (
|
||||
<IconifyIcon height={14} icon="lucide:diamond" width={14} />
|
||||
) : levelMode === 'exploded' ? (
|
||||
<IconifyIcon height={14} icon="charm:stack-pop" width={14} />
|
||||
) : (
|
||||
<IconifyIcon height={14} icon="charm:stack-push" width={14} />
|
||||
)}
|
||||
<span className="font-medium text-xs">{levelModeLabels[levelMode] ?? 'Stack'}</span>
|
||||
</button>
|
||||
</ToolbarTooltip>
|
||||
)
|
||||
}
|
||||
|
||||
function WallModeToggle() {
|
||||
const wallMode = useViewer((state) => state.wallMode)
|
||||
const setWallMode = useViewer((state) => state.setWallMode)
|
||||
const config = wallModeConfig[wallMode] ?? wallModeConfig.cutaway!
|
||||
|
||||
const cycle = () => {
|
||||
const index = wallModeOrder.indexOf(wallMode as (typeof wallModeOrder)[number])
|
||||
const next = wallModeOrder[(index + 1) % wallModeOrder.length]
|
||||
if (next) setWallMode(next)
|
||||
}
|
||||
|
||||
return (
|
||||
<ToolbarTooltip label={`Walls: ${config.label}`}>
|
||||
<button
|
||||
className={cn(
|
||||
TOOLBAR_BTN,
|
||||
'w-auto gap-1.5 px-2.5',
|
||||
wallMode !== 'cutaway'
|
||||
? 'bg-white/10'
|
||||
: 'opacity-60 grayscale hover:opacity-100 hover:grayscale-0',
|
||||
)}
|
||||
onClick={cycle}
|
||||
type="button"
|
||||
>
|
||||
<Image alt="" className="h-4 w-4 object-contain" height={16} src={config.icon} width={16} />
|
||||
<span className="font-medium text-xs">{config.label}</span>
|
||||
</button>
|
||||
</ToolbarTooltip>
|
||||
)
|
||||
}
|
||||
|
||||
function GridVisibilityToggle() {
|
||||
const showGrid = useViewer((state) => state.showGrid)
|
||||
const setShowGrid = useViewer((state) => state.setShowGrid)
|
||||
|
||||
return (
|
||||
<ToolbarTooltip label={`Grid: ${showGrid ? 'Visible' : 'Hidden'}`}>
|
||||
<button
|
||||
aria-label={`Grid: ${showGrid ? 'Visible' : 'Hidden'}`}
|
||||
aria-pressed={showGrid}
|
||||
className={cn(
|
||||
TOOLBAR_BTN,
|
||||
'w-auto gap-1.5 px-2.5',
|
||||
showGrid
|
||||
? 'bg-white/10 text-foreground/90'
|
||||
: 'opacity-60 grayscale hover:opacity-100 hover:grayscale-0',
|
||||
)}
|
||||
onClick={() => setShowGrid(!showGrid)}
|
||||
type="button"
|
||||
>
|
||||
<Grid2X2 className="h-3.5 w-3.5" />
|
||||
{showGrid ? <Eye className="h-3.5 w-3.5" /> : <EyeOff className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
</ToolbarTooltip>
|
||||
)
|
||||
}
|
||||
|
||||
function UnitToggle() {
|
||||
const unit = useViewer((state) => state.unit)
|
||||
const setUnit = useViewer((state) => state.setUnit)
|
||||
|
||||
return (
|
||||
<ToolbarTooltip label={unit === 'metric' ? 'Metric (m)' : 'Imperial (ft)'}>
|
||||
<button
|
||||
className={TOOLBAR_BTN}
|
||||
onClick={() => setUnit(unit === 'metric' ? 'imperial' : 'metric')}
|
||||
type="button"
|
||||
>
|
||||
<span className="font-semibold text-[10px]">{unit === 'metric' ? 'm' : 'ft'}</span>
|
||||
</button>
|
||||
</ToolbarTooltip>
|
||||
)
|
||||
}
|
||||
|
||||
function ThemeToggle() {
|
||||
const theme = useViewer((state) => state.theme)
|
||||
const setTheme = useViewer((state) => state.setTheme)
|
||||
|
||||
return (
|
||||
<ToolbarTooltip label={theme === 'dark' ? 'Dark' : 'Light'}>
|
||||
<button
|
||||
className={cn(TOOLBAR_BTN, theme === 'dark' ? 'text-indigo-400/70' : 'text-amber-400/70')}
|
||||
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
|
||||
type="button"
|
||||
>
|
||||
{theme === 'dark' ? <Moon className="h-3.5 w-3.5" /> : <Sun className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
</ToolbarTooltip>
|
||||
)
|
||||
}
|
||||
|
||||
function CameraModeToggle() {
|
||||
const cameraMode = useViewer((state) => state.cameraMode)
|
||||
const setCameraMode = useViewer((state) => state.setCameraMode)
|
||||
|
||||
return (
|
||||
<ToolbarTooltip label={cameraMode === 'perspective' ? 'Perspective' : 'Orthographic'}>
|
||||
<button
|
||||
className={cn(
|
||||
TOOLBAR_BTN,
|
||||
cameraMode === 'orthographic' && 'bg-white/10 text-foreground/90',
|
||||
)}
|
||||
onClick={() => setCameraMode(cameraMode === 'perspective' ? 'orthographic' : 'perspective')}
|
||||
type="button"
|
||||
>
|
||||
{cameraMode === 'perspective' ? (
|
||||
<IconifyIcon height={16} icon="icon-park-outline:perspective" width={16} />
|
||||
) : (
|
||||
<IconifyIcon height={16} icon="vaadin:grid" width={16} />
|
||||
)}
|
||||
</button>
|
||||
</ToolbarTooltip>
|
||||
)
|
||||
}
|
||||
|
||||
function WalkthroughButton() {
|
||||
const isFirstPersonMode = useEditor((state) => state.isFirstPersonMode)
|
||||
const setFirstPersonMode = useEditor((state) => state.setFirstPersonMode)
|
||||
|
||||
return (
|
||||
<ToolbarTooltip label="Walkthrough">
|
||||
<button
|
||||
className={cn(
|
||||
TOOLBAR_BTN,
|
||||
isFirstPersonMode && 'bg-emerald-500/15 text-emerald-400 hover:bg-emerald-500/20',
|
||||
)}
|
||||
onClick={() => setFirstPersonMode(!isFirstPersonMode)}
|
||||
type="button"
|
||||
>
|
||||
<Footprints className="h-4 w-4" />
|
||||
</button>
|
||||
</ToolbarTooltip>
|
||||
)
|
||||
}
|
||||
|
||||
function PreviewButton() {
|
||||
return (
|
||||
<ToolbarTooltip label="Preview mode">
|
||||
<button
|
||||
className="flex items-center gap-1.5 px-2.5 font-medium text-muted-foreground/80 text-xs transition-colors hover:bg-white/8 hover:text-foreground/90"
|
||||
onClick={() => useEditor.getState().setPreviewMode(true)}
|
||||
type="button"
|
||||
>
|
||||
<Eye className="h-3.5 w-3.5 shrink-0" />
|
||||
<span>Preview</span>
|
||||
</button>
|
||||
</ToolbarTooltip>
|
||||
)
|
||||
}
|
||||
|
||||
export function CommunityViewerToolbarLeft() {
|
||||
return (
|
||||
<>
|
||||
<CollapseSidebarButton />
|
||||
<ViewModeControl />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function CommunityViewerToolbarRight() {
|
||||
return (
|
||||
<div className={TOOLBAR_CONTAINER}>
|
||||
<LevelModeToggle />
|
||||
<WallModeToggle />
|
||||
<GridVisibilityToggle />
|
||||
<div className="my-1.5 w-px bg-border/50" />
|
||||
<UnitToggle />
|
||||
<ThemeToggle />
|
||||
<CameraModeToggle />
|
||||
<div className="my-1.5 w-px bg-border/50" />
|
||||
<WalkthroughButton />
|
||||
<PreviewButton />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+6
-27
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* Environment variable validation for the editor app.
|
||||
*
|
||||
* This file validates that required environment variables are set at runtime.
|
||||
* Variables are defined in the root .env file.
|
||||
* This file validates environment variables used by the standalone editor app.
|
||||
* Values are loaded from the repo root .env.local by package scripts.
|
||||
*
|
||||
* @see https://env.t3.gg/docs/nextjs
|
||||
*/
|
||||
@@ -13,42 +13,21 @@ export const env = createEnv({
|
||||
/**
|
||||
* Server-side environment variables (not exposed to client)
|
||||
*/
|
||||
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(),
|
||||
},
|
||||
server: {},
|
||||
|
||||
/**
|
||||
* Client-side environment variables (exposed to browser via NEXT_PUBLIC_)
|
||||
*/
|
||||
client: {
|
||||
NEXT_PUBLIC_SUPABASE_URL: z.string().min(1),
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY: z.string().optional(),
|
||||
NEXT_PUBLIC_ASSETS_CDN_URL: z.string().optional(),
|
||||
},
|
||||
|
||||
/**
|
||||
* Runtime values - pulls from process.env
|
||||
*/
|
||||
runtimeEnv: {
|
||||
// Server
|
||||
POSTGRES_URL: process.env.POSTGRES_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,
|
||||
NEXT_PUBLIC_ASSETS_CDN_URL:
|
||||
process.env.NEXT_PUBLIC_ASSETS_CDN_URL ?? process.env.NEXT_PUBLIC_EDITOR_ASSETS_CDN_URL,
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import type { NextConfig } from 'next'
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
logging: {
|
||||
browserToTerminal: true,
|
||||
},
|
||||
typescript: {
|
||||
ignoreBuildErrors: true,
|
||||
},
|
||||
|
||||
@@ -4,23 +4,26 @@
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "dotenv -e ./.env.local --override -- next dev --port 3002",
|
||||
"build": "dotenv -e ./.env.local --override -- next build",
|
||||
"dev": "dotenv -e ../../.env.local -- next dev --port 3002",
|
||||
"build": "dotenv -e ../../.env.local -- next build",
|
||||
"start": "next start",
|
||||
"lint": "biome lint",
|
||||
"check-types": "next typegen && tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@iconify/react": "^6.0.2",
|
||||
"@number-flow/react": "^0.5.14",
|
||||
"@pascal-app/core": "*",
|
||||
"@pascal-app/editor": "*",
|
||||
"@pascal-app/mcp": "*",
|
||||
"@pascal-app/viewer": "*",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@react-three/drei": "^10.7.7",
|
||||
"@react-three/fiber": "^9.5.0",
|
||||
"@tailwindcss/postcss": "^4.2.1",
|
||||
"clsx": "^2.1.1",
|
||||
"geist": "^1.7.0",
|
||||
"lucide-react": "^1.7.0",
|
||||
"next": "16.2.1",
|
||||
"postcss": "^8.5.6",
|
||||
"react": "^19.2.4",
|
||||
@@ -37,9 +40,9 @@
|
||||
"@types/react": "19.2.2",
|
||||
"@types/react-dom": "19.2.2",
|
||||
"agentation": "^2.3.2",
|
||||
"react-grab": "^0.1.25",
|
||||
"react-grab": "^0.1.29",
|
||||
"react-scan": "^0.5.3",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "5.9.3"
|
||||
"typescript": "6.0.2"
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
@@ -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 |
@@ -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"
|
||||
}
|
||||
+21
-4
@@ -12,23 +12,40 @@
|
||||
"check:fix": "biome check --write",
|
||||
"check-types": "turbo run check-types",
|
||||
"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: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:major": "gh workflow run release.yml -f package=both -f bump=major"
|
||||
"release:editor": "gh workflow run release.yml -f package=editor -f bump=patch",
|
||||
"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": {
|
||||
"@biomejs/biome": "^2.4.6",
|
||||
"dotenv-cli": "^11.0.0",
|
||||
"turbo": "^2.8.15",
|
||||
"typescript": "5.9.3",
|
||||
"typescript": "6.0.2",
|
||||
"ultracite": "^7.2.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"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": [
|
||||
"apps/*",
|
||||
"packages/*",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@pascal-app/core",
|
||||
"version": "0.6.0",
|
||||
"version": "0.8.0",
|
||||
"description": "Core library for Pascal 3D building editor",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
@@ -73,9 +73,10 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@pascal/typescript-config": "*",
|
||||
"@types/bun": "^1.3.0",
|
||||
"@types/react": "^19.2.2",
|
||||
"typescript": "5.9.3",
|
||||
"@types/three": "^0.184.0"
|
||||
"@types/three": "^0.184.0",
|
||||
"typescript": "6.0.2"
|
||||
},
|
||||
"keywords": [
|
||||
"3d",
|
||||
|
||||
@@ -29,12 +29,13 @@ export interface GridEvent {
|
||||
/** World-space intersection point on the grid plane. */
|
||||
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.
|
||||
* Use this for placing or committing anything that lives inside a building
|
||||
* (walls, slabs, items, etc.).
|
||||
* Use this for placing/committing anything that lives inside a building (walls, slabs, items, etc.).
|
||||
*/
|
||||
localPosition: [number, number, number]
|
||||
faceIndex?: number
|
||||
object: Object3D
|
||||
nativeEvent: ThreeEvent<PointerEvent>
|
||||
}
|
||||
|
||||
@@ -68,7 +69,7 @@ export type WindowEvent = NodeEvent<WindowNode>
|
||||
export type DoorEvent = NodeEvent<DoorNode>
|
||||
export type ElevatorEvent = NodeEvent<ElevatorNode>
|
||||
|
||||
// Event suffixes, exported for use in hooks
|
||||
// Event suffixes - exported for use in hooks
|
||||
export const eventSuffixes = [
|
||||
'click',
|
||||
'move',
|
||||
@@ -101,7 +102,7 @@ export interface ThumbnailGenerateEvent {
|
||||
/**
|
||||
* When true, snap levels to their true positions before capturing (for a
|
||||
* 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.
|
||||
*/
|
||||
snapLevels?: boolean
|
||||
@@ -109,8 +110,10 @@ export interface ThumbnailGenerateEvent {
|
||||
|
||||
export interface CameraControlFitSceneEvent {
|
||||
/**
|
||||
* XZ-plane axis-aligned bounds for camera framing. Omitted values let the
|
||||
* listener choose its default framing pose.
|
||||
* XZ-plane axis-aligned bounds of the scene's geometry, computed from the
|
||||
* 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?: {
|
||||
min: [number, number]
|
||||
|
||||
@@ -46,6 +46,8 @@ export { getRenderableSlabPolygon } from './lib/slab-polygon'
|
||||
export {
|
||||
detectSpacesForLevel,
|
||||
initSpaceDetectionSync,
|
||||
planAutoSlabsForLevel,
|
||||
type AutoSlabSyncPlan,
|
||||
type Space,
|
||||
wallTouchesOthers,
|
||||
} from './lib/space-detection'
|
||||
@@ -122,6 +124,15 @@ export {
|
||||
type WallMiterBoundaryPoints,
|
||||
type WallMiterData,
|
||||
} from './systems/wall/wall-mitering'
|
||||
export {
|
||||
constrainWallMoveDeltaToAxis,
|
||||
getPerpendicularWallMoveAxis,
|
||||
planWallMoveJunctions,
|
||||
type WallMoveBridgePlan,
|
||||
type WallMoveAxis,
|
||||
type WallMoveJunctionPlan,
|
||||
type WallPlanPoint,
|
||||
} from './systems/wall/wall-move'
|
||||
export type { SceneGraph } from './utils/clone-scene-graph'
|
||||
export { cloneLevelSubtree, cloneSceneGraph, forkSceneGraph } from './utils/clone-scene-graph'
|
||||
export { isObject } from './utils/types'
|
||||
|
||||
@@ -6,10 +6,7 @@ export function insetPolygonFromCentroid(
|
||||
return polygon.map(([x, z]) => [x, z] as [number, number])
|
||||
}
|
||||
|
||||
const centroid = polygon.reduce(
|
||||
(acc, [x, z]) => ({ x: acc.x + x, z: acc.z + z }),
|
||||
{ x: 0, z: 0 },
|
||||
)
|
||||
const centroid = polygon.reduce((acc, [x, z]) => ({ x: acc.x + x, z: acc.z + z }), { x: 0, z: 0 })
|
||||
centroid.x /= Math.max(polygon.length, 1)
|
||||
centroid.z /= Math.max(polygon.length, 1)
|
||||
|
||||
@@ -70,7 +67,10 @@ function dedupePolygonPoints(
|
||||
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) {
|
||||
return points.map(([x, z]) => [x, z] as [number, number])
|
||||
}
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
import {
|
||||
getClampedWallCurveOffset,
|
||||
getWallCurveFrameAt,
|
||||
isCurvedWall,
|
||||
} from '../systems/wall/wall-curve'
|
||||
import { CeilingNode, SlabNode, type CeilingNode as CeilingNodeType, type SlabNode as SlabNodeType, type WallNode } from '../schema'
|
||||
CeilingNode,
|
||||
type CeilingNode as CeilingNodeType,
|
||||
SlabNode,
|
||||
type SlabNode as SlabNodeType,
|
||||
type WallNode,
|
||||
} from '../schema'
|
||||
import {
|
||||
getSceneHistoryPauseDepth,
|
||||
pauseSceneHistory,
|
||||
resumeSceneHistory,
|
||||
} from '../store/history-control'
|
||||
import {
|
||||
getClampedWallCurveOffset,
|
||||
getWallCurveFrameAt,
|
||||
isCurvedWall,
|
||||
} from '../systems/wall/wall-curve'
|
||||
import { simplifyClosedPolygon } from './polygon-geometry'
|
||||
|
||||
type Point2D = { x: number; y: number }
|
||||
@@ -35,6 +41,12 @@ type DetectedRoom = {
|
||||
bbox: ReturnType<typeof bboxOf>
|
||||
}
|
||||
|
||||
export type AutoSlabSyncPlan = {
|
||||
create: SlabNodeType[]
|
||||
update: Array<{ id: SlabNodeType['id']; data: Partial<SlabNodeType> }>
|
||||
delete: Array<SlabNodeType['id']>
|
||||
}
|
||||
|
||||
const DEFAULT_AUTO_SLAB_ELEVATION = 0.05
|
||||
const DEFAULT_AUTO_CEILING_HEIGHT = 2.5
|
||||
const ROOM_CURVE_TOLERANCE = 0.04
|
||||
@@ -147,10 +159,10 @@ function polygonCentroid(points: Point2D[]) {
|
||||
}
|
||||
|
||||
function bboxOf(points: Point2D[]) {
|
||||
let minX = Infinity
|
||||
let minY = Infinity
|
||||
let maxX = -Infinity
|
||||
let maxY = -Infinity
|
||||
let minX = Number.POSITIVE_INFINITY
|
||||
let minY = Number.POSITIVE_INFINITY
|
||||
let maxX = Number.NEGATIVE_INFINITY
|
||||
let maxY = Number.NEGATIVE_INFINITY
|
||||
|
||||
for (const point of points) {
|
||||
minX = Math.min(minX, point.x)
|
||||
@@ -216,7 +228,13 @@ function sampleWallPointsForRoomDetection(
|
||||
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 midPoint = getWallCurveFrameAt(wall, midT).point
|
||||
const deviation = pointLineDistance(midPoint, p0, p1)
|
||||
@@ -361,7 +379,7 @@ function extractRoomPolygons(walls: WallNode[]): Point2D[][] {
|
||||
|
||||
const signedArea = polygonArea(polygon)
|
||||
if (signedArea <= 0) continue
|
||||
if (signedArea < 0.5 || signedArea > 10000) continue
|
||||
if (signedArea < 0.5 || signedArea > 10_000) continue
|
||||
|
||||
const signature = polygonSignature(polygon)
|
||||
if (faces.some((face) => polygonSignature(face) === signature)) continue
|
||||
@@ -442,10 +460,7 @@ function nextAutoRoomName(
|
||||
return `Room ${maxIndex + 1} ${suffix}`
|
||||
}
|
||||
|
||||
function sameTuplePolygon(
|
||||
current: Array<[number, number]>,
|
||||
next: Array<[number, number]>,
|
||||
) {
|
||||
function sameTuplePolygon(current: Array<[number, number]>, next: Array<[number, number]>) {
|
||||
return (
|
||||
current.length === next.length &&
|
||||
current.every((point, index) => point[0] === next[index]?.[0] && point[1] === next[index]?.[1])
|
||||
@@ -479,12 +494,10 @@ function buildSpace(levelId: string, polygon: Point2D[]): Space {
|
||||
}
|
||||
}
|
||||
|
||||
function syncAutoSlabsForLevel(
|
||||
levelId: string,
|
||||
export function planAutoSlabsForLevel(
|
||||
roomPolygons: Point2D[][],
|
||||
existingSlabs: SlabNodeType[],
|
||||
sceneStore: any,
|
||||
) {
|
||||
): AutoSlabSyncPlan {
|
||||
const manualSlabs = existingSlabs.filter((slab) => !slab.autoFromWalls)
|
||||
const manualSignatures = new Set(
|
||||
manualSlabs.map((slab) => polygonSignature(slab.polygon.map(pointFromTuple))),
|
||||
@@ -609,16 +622,31 @@ function syncAutoSlabsForLevel(
|
||||
)
|
||||
}
|
||||
|
||||
if (slabsToDelete.length > 0) {
|
||||
sceneStore.getState().deleteNodes(slabsToDelete)
|
||||
return {
|
||||
create: slabsToCreate,
|
||||
update: slabsToUpdate,
|
||||
delete: slabsToDelete,
|
||||
}
|
||||
}
|
||||
|
||||
function syncAutoSlabsForLevel(
|
||||
levelId: string,
|
||||
roomPolygons: Point2D[][],
|
||||
existingSlabs: SlabNodeType[],
|
||||
sceneStore: any,
|
||||
) {
|
||||
const plan = planAutoSlabsForLevel(roomPolygons, existingSlabs)
|
||||
|
||||
if (plan.delete.length > 0) {
|
||||
sceneStore.getState().deleteNodes(plan.delete)
|
||||
}
|
||||
|
||||
if (slabsToUpdate.length > 0) {
|
||||
sceneStore.getState().updateNodes(slabsToUpdate)
|
||||
if (plan.update.length > 0) {
|
||||
sceneStore.getState().updateNodes(plan.update)
|
||||
}
|
||||
|
||||
if (slabsToCreate.length > 0) {
|
||||
sceneStore.getState().createNodes(slabsToCreate.map((node) => ({ node, parentId: levelId })))
|
||||
if (plan.create.length > 0) {
|
||||
sceneStore.getState().createNodes(plan.create.map((node) => ({ node, parentId: levelId })))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -727,7 +755,9 @@ function syncAutoCeilingsForLevel(
|
||||
const polygon = updatesById.get(ceiling.id)
|
||||
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]
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import {
|
||||
type MaterialPresetPayload,
|
||||
type MaterialTarget,
|
||||
MaterialTarget as MaterialTargetSchema,
|
||||
} from './schema/material'
|
||||
|
||||
export type MaterialCatalogItem = {
|
||||
@@ -12,6 +14,32 @@ export type MaterialCatalogItem = {
|
||||
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 = [
|
||||
'wood',
|
||||
'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 { ALLOWED_ORIGINS_ENV, AssetUrl } from './asset-url'
|
||||
|
||||
|
||||
@@ -13,9 +13,9 @@ export const FenceNode = BaseNode.extend({
|
||||
materialPreset: z.string().optional(),
|
||||
start: z.tuple([z.number(), z.number()]),
|
||||
end: z.tuple([z.number(), z.number()]),
|
||||
curveOffset: z.number().optional(),
|
||||
height: z.number().default(1.8),
|
||||
thickness: z.number().default(0.08),
|
||||
curveOffset: z.number().optional(),
|
||||
baseHeight: z.number().default(0.22),
|
||||
postSpacing: z.number().default(2),
|
||||
postSize: z.number().default(0.1),
|
||||
@@ -30,8 +30,8 @@ export const FenceNode = BaseNode.extend({
|
||||
dedent`
|
||||
Fence node - used to represent a fence segment in the building/site 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
|
||||
- curveOffset: midpoint sagitta offset used to bend the fence into an arc
|
||||
- baseHeight/postSpacing/postSize/topRailHeight: exact geometric controls from the plan3D fence model
|
||||
- groundClearance/edgeInset/baseStyle: fence support and inset configuration
|
||||
- showInfill: whether to draw intermediate posts/slats between end posts
|
||||
|
||||
@@ -83,6 +83,18 @@ const assetSchema = z.object({
|
||||
// Optional top-down 2D image shown inside the item's footprint on the
|
||||
// floor plan. When present, replaces the default diagonal-cross marker.
|
||||
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,
|
||||
dimensions: z.tuple([z.number(), z.number(), z.number()]).default([1, 1, 1]), // [w, h, d]
|
||||
attachTo: z.enum(['wall', 'wall-side', 'ceiling']).optional(),
|
||||
|
||||
@@ -1,25 +1,26 @@
|
||||
import dedent from 'dedent'
|
||||
import { z } from 'zod'
|
||||
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'
|
||||
|
||||
export type RoofSurfaceMaterialRole = 'top' | 'edge' | 'wall'
|
||||
export type RoofSurfaceMaterialSpec = {
|
||||
material?: MaterialSchema
|
||||
material?: MaterialSchemaType
|
||||
materialPreset?: string
|
||||
}
|
||||
|
||||
export const RoofNode = BaseNode.extend({
|
||||
id: objectId('roof'),
|
||||
type: nodeType('roof'),
|
||||
material: MaterialSchemaSchema.optional(),
|
||||
material: MaterialSchema.optional(),
|
||||
materialPreset: z.string().optional(),
|
||||
topMaterial: MaterialSchemaSchema.optional(),
|
||||
topMaterial: MaterialSchema.optional(),
|
||||
topMaterialPreset: z.string().optional(),
|
||||
edgeMaterial: MaterialSchemaSchema.optional(),
|
||||
edgeMaterial: MaterialSchema.optional(),
|
||||
edgeMaterialPreset: z.string().optional(),
|
||||
wallMaterial: MaterialSchemaSchema.optional(),
|
||||
wallMaterial: MaterialSchema.optional(),
|
||||
wallMaterialPreset: z.string().optional(),
|
||||
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
|
||||
// Rotation around Y axis in radians
|
||||
@@ -54,7 +55,8 @@ export function getEffectiveRoofSurfaceMaterial(
|
||||
if (node.topMaterial !== undefined || typeof node.topMaterialPreset === 'string') {
|
||||
return {
|
||||
material: node.topMaterial,
|
||||
materialPreset: typeof node.topMaterialPreset === 'string' ? node.topMaterialPreset : undefined,
|
||||
materialPreset:
|
||||
typeof node.topMaterialPreset === 'string' ? node.topMaterialPreset : undefined,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import dedent from 'dedent'
|
||||
import { z } from 'zod'
|
||||
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'
|
||||
|
||||
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 StairSurfaceMaterialRole = 'railing' | 'tread' | 'side'
|
||||
export type StairSurfaceMaterialSpec = {
|
||||
material?: MaterialSchema
|
||||
material?: MaterialSchemaType
|
||||
materialPreset?: string
|
||||
}
|
||||
|
||||
export const StairNode = BaseNode.extend({
|
||||
id: objectId('stair'),
|
||||
type: nodeType('stair'),
|
||||
material: MaterialSchemaSchema.optional(),
|
||||
material: MaterialSchema.optional(),
|
||||
materialPreset: z.string().optional(),
|
||||
railingMaterial: MaterialSchemaSchema.optional(),
|
||||
railingMaterial: MaterialSchema.optional(),
|
||||
railingMaterialPreset: z.string().optional(),
|
||||
treadMaterial: MaterialSchemaSchema.optional(),
|
||||
treadMaterial: MaterialSchema.optional(),
|
||||
treadMaterialPreset: z.string().optional(),
|
||||
sideMaterial: MaterialSchemaSchema.optional(),
|
||||
sideMaterial: MaterialSchema.optional(),
|
||||
sideMaterialPreset: z.string().optional(),
|
||||
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
|
||||
// Rotation around Y axis in radians
|
||||
@@ -126,18 +127,26 @@ export function getEffectiveStairSurfaceMaterial(
|
||||
|
||||
const treadFallback = {
|
||||
material: node.treadMaterial,
|
||||
materialPreset: typeof node.treadMaterialPreset === 'string' ? node.treadMaterialPreset : undefined,
|
||||
materialPreset:
|
||||
typeof node.treadMaterialPreset === 'string' ? node.treadMaterialPreset : undefined,
|
||||
}
|
||||
const sideFallback = {
|
||||
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
|
||||
}
|
||||
|
||||
if (role === 'side' && (treadFallback.material !== undefined || treadFallback.materialPreset !== undefined)) {
|
||||
if (
|
||||
role === 'side' &&
|
||||
(treadFallback.material !== undefined || treadFallback.materialPreset !== undefined)
|
||||
) {
|
||||
return treadFallback
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,9 @@ import type { CollectionId } from '../../schema/collections'
|
||||
import type { SceneState } from '../use-scene'
|
||||
|
||||
type AnyContainerNode = AnyNode & { children: string[] }
|
||||
type NodeCreateOp = { node: AnyNode; parentId?: AnyNodeId }
|
||||
type NodeUpdateOp = { id: AnyNodeId; data: Partial<AnyNode> }
|
||||
type NodeDeleteOp = AnyNodeId
|
||||
type WallAttachmentUpdate = { id: AnyNodeId; data: Partial<AnyNode> }
|
||||
type WallMergePlan = {
|
||||
primaryWallId: AnyNodeId
|
||||
@@ -122,7 +125,7 @@ function buildMergedWallAttachmentUpdates(
|
||||
const wallChildren = [...(primary.children ?? []), ...(secondary.children ?? [])] as AnyNodeId[]
|
||||
for (const childId of wallChildren) {
|
||||
const child = nodes[childId]
|
||||
if (!child || !('position' in child) || !Array.isArray(child.position)) {
|
||||
if (!(child && 'position' in child && Array.isArray(child.position))) {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -190,10 +193,12 @@ function buildWallMergePlans(
|
||||
})
|
||||
const [primary, secondary] = sortedCandidates
|
||||
if (
|
||||
!primary ||
|
||||
!secondary ||
|
||||
!areWallStylesCompatible(primary, secondary) ||
|
||||
!areWallsCollinearAcrossPoint(primary, secondary, junction)
|
||||
!(
|
||||
primary &&
|
||||
secondary &&
|
||||
areWallStylesCompatible(primary, secondary) &&
|
||||
areWallsCollinearAcrossPoint(primary, secondary, junction)
|
||||
)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
@@ -230,7 +235,7 @@ function buildWallMergePlans(
|
||||
export const createNodesAction = (
|
||||
set: (fn: (state: SceneState) => Partial<SceneState>) => void,
|
||||
get: () => SceneState,
|
||||
ops: { node: AnyNode; parentId?: AnyNodeId }[],
|
||||
ops: NodeCreateOp[],
|
||||
) => {
|
||||
if (get().readOnly) return
|
||||
set((state) => {
|
||||
@@ -275,6 +280,144 @@ export const createNodesAction = (
|
||||
ops.forEach(({ node, parentId }) => {
|
||||
get().markDirty(node.id)
|
||||
if (parentId) get().markDirty(parentId)
|
||||
else if (node.parentId) get().markDirty(node.parentId as AnyNodeId)
|
||||
})
|
||||
}
|
||||
|
||||
export const applyNodeChangesAction = (
|
||||
set: (fn: (state: SceneState) => Partial<SceneState>) => void,
|
||||
get: () => SceneState,
|
||||
changes: { create?: NodeCreateOp[]; update?: NodeUpdateOp[]; delete?: NodeDeleteOp[] },
|
||||
) => {
|
||||
if (get().readOnly) return
|
||||
|
||||
const createOps = changes.create ?? []
|
||||
const updateOps = changes.update ?? []
|
||||
const deleteOps = changes.delete ?? []
|
||||
const nodesToMarkDirty = new Set<AnyNodeId>()
|
||||
const parentsToMarkDirty = new Set<AnyNodeId>()
|
||||
|
||||
set((state) => {
|
||||
const nextNodes = { ...state.nodes }
|
||||
const nextCollections = { ...state.collections }
|
||||
const nextRootIds = [...state.rootNodeIds]
|
||||
let resolvedRootIds = nextRootIds
|
||||
|
||||
for (const { id, data } of updateOps) {
|
||||
const currentNode = nextNodes[id]
|
||||
if (!currentNode) continue
|
||||
|
||||
if (data.parentId !== undefined && data.parentId !== currentNode.parentId) {
|
||||
const oldParentId = currentNode.parentId as AnyNodeId | null
|
||||
if (oldParentId && nextNodes[oldParentId]) {
|
||||
const oldParent = nextNodes[oldParentId] as AnyContainerNode
|
||||
nextNodes[oldParent.id] = {
|
||||
...oldParent,
|
||||
children: oldParent.children.filter((childId) => childId !== id),
|
||||
} as AnyNode
|
||||
parentsToMarkDirty.add(oldParent.id)
|
||||
}
|
||||
|
||||
const newParentId = data.parentId as AnyNodeId | null
|
||||
if (newParentId && nextNodes[newParentId]) {
|
||||
const newParent = nextNodes[newParentId] as AnyContainerNode
|
||||
nextNodes[newParent.id] = {
|
||||
...newParent,
|
||||
children: Array.from(new Set([...newParent.children, id])),
|
||||
} as AnyNode
|
||||
parentsToMarkDirty.add(newParent.id)
|
||||
}
|
||||
}
|
||||
|
||||
nextNodes[id] = { ...currentNode, ...data } as AnyNode
|
||||
nodesToMarkDirty.add(id)
|
||||
}
|
||||
|
||||
for (const { node, parentId } of createOps) {
|
||||
const effectiveParentId = parentId ?? (node.parentId as AnyNodeId | null) ?? null
|
||||
const newNode = {
|
||||
...node,
|
||||
parentId: effectiveParentId,
|
||||
} as AnyNode
|
||||
|
||||
nextNodes[newNode.id as AnyNodeId] = newNode
|
||||
nodesToMarkDirty.add(newNode.id as AnyNodeId)
|
||||
|
||||
if (effectiveParentId && nextNodes[effectiveParentId]) {
|
||||
const parent = nextNodes[effectiveParentId]
|
||||
if ('children' in parent && Array.isArray(parent.children)) {
|
||||
nextNodes[effectiveParentId] = {
|
||||
...parent,
|
||||
children: Array.from(new Set([...parent.children, newNode.id])) as any,
|
||||
}
|
||||
parentsToMarkDirty.add(effectiveParentId)
|
||||
}
|
||||
} else if (!effectiveParentId && !nextRootIds.includes(newNode.id as AnyNodeId)) {
|
||||
nextRootIds.push(newNode.id as AnyNodeId)
|
||||
}
|
||||
}
|
||||
|
||||
const allIdsToDelete = new Set<AnyNodeId>()
|
||||
const collectDelete = (id: AnyNodeId) => {
|
||||
if (allIdsToDelete.has(id)) return
|
||||
allIdsToDelete.add(id)
|
||||
const node = nextNodes[id]
|
||||
if (node && 'children' in node && Array.isArray(node.children)) {
|
||||
for (const childId of node.children) {
|
||||
collectDelete(childId as AnyNodeId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const id of deleteOps) {
|
||||
collectDelete(id)
|
||||
}
|
||||
|
||||
for (const id of allIdsToDelete) {
|
||||
const node = nextNodes[id]
|
||||
if (!node) continue
|
||||
|
||||
const parentId = node.parentId as AnyNodeId | null
|
||||
if (parentId && nextNodes[parentId] && !allIdsToDelete.has(parentId)) {
|
||||
const parent = nextNodes[parentId] as AnyContainerNode
|
||||
if (parent.children) {
|
||||
nextNodes[parent.id] = {
|
||||
...parent,
|
||||
children: parent.children.filter((childId) => childId !== id),
|
||||
} as AnyNode
|
||||
parentsToMarkDirty.add(parent.id)
|
||||
}
|
||||
}
|
||||
|
||||
resolvedRootIds = resolvedRootIds.filter((rootId) => rootId !== id)
|
||||
|
||||
if ('collectionIds' in node && node.collectionIds) {
|
||||
for (const collectionId of node.collectionIds as CollectionId[]) {
|
||||
const collection = nextCollections[collectionId]
|
||||
if (collection) {
|
||||
nextCollections[collectionId] = {
|
||||
...collection,
|
||||
nodeIds: collection.nodeIds.filter((nodeId) => nodeId !== id),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
delete nextNodes[id]
|
||||
}
|
||||
|
||||
return { nodes: nextNodes, rootNodeIds: resolvedRootIds, collections: nextCollections }
|
||||
})
|
||||
|
||||
nodesToMarkDirty.forEach((id) => get().markDirty(id))
|
||||
parentsToMarkDirty.forEach((id) => {
|
||||
get().markDirty(id)
|
||||
const parent = get().nodes[id]
|
||||
if (parent && 'children' in parent && Array.isArray(parent.children)) {
|
||||
for (const childId of parent.children) {
|
||||
get().markDirty(childId as AnyNodeId)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -111,7 +111,7 @@ function migrateWallSurfaceMaterials(node: Record<string, any>) {
|
||||
materialPreset: typeof node.materialPreset === 'string' ? node.materialPreset : undefined,
|
||||
}
|
||||
|
||||
if (!hasInterior && !hasExterior) {
|
||||
if (!(hasInterior || hasExterior)) {
|
||||
if (legacyFinish.material === undefined && legacyFinish.materialPreset === undefined) {
|
||||
return node
|
||||
}
|
||||
@@ -174,7 +174,7 @@ function migrateStairSurfaceMaterials(node: Record<string, any>) {
|
||||
return legacyFinish
|
||||
}
|
||||
|
||||
if (!hasRailing && !hasTread && !hasSide) {
|
||||
if (!(hasRailing || hasTread || hasSide)) {
|
||||
if (legacyFinish.material === undefined && legacyFinish.materialPreset === undefined) {
|
||||
return node
|
||||
}
|
||||
@@ -236,7 +236,7 @@ function migrateRoofSurfaceMaterials(node: Record<string, any>) {
|
||||
materialPreset: typeof node.materialPreset === 'string' ? node.materialPreset : undefined,
|
||||
}
|
||||
|
||||
if (!hasTop && !hasEdge && !hasWall) {
|
||||
if (!(hasTop || hasEdge || hasWall)) {
|
||||
if (legacyFinish.material === undefined && legacyFinish.materialPreset === undefined) {
|
||||
return node
|
||||
}
|
||||
@@ -350,7 +350,7 @@ function migrateNodes(nodes: Record<string, any>): Record<string, AnyNode> {
|
||||
}
|
||||
|
||||
function getNodeChildIds(node: AnyNode): AnyNodeId[] {
|
||||
if (!('children' in node) || !Array.isArray(node.children)) {
|
||||
if (!('children' in node && Array.isArray(node.children))) {
|
||||
return []
|
||||
}
|
||||
|
||||
@@ -438,6 +438,11 @@ export type SceneState = {
|
||||
|
||||
createNode: (node: AnyNode, parentId?: AnyNodeId) => void
|
||||
createNodes: (ops: { node: AnyNode; parentId?: AnyNodeId }[]) => void
|
||||
applyNodeChanges: (changes: {
|
||||
create?: { node: AnyNode; parentId?: AnyNodeId }[]
|
||||
update?: { id: AnyNodeId; data: Partial<AnyNode> }[]
|
||||
delete?: AnyNodeId[]
|
||||
}) => void
|
||||
|
||||
updateNode: (id: AnyNodeId, data: Partial<AnyNode>) => void
|
||||
updateNodes: (updates: { id: AnyNodeId; data: Partial<AnyNode> }[]) => void
|
||||
@@ -511,6 +516,13 @@ const useScene: UseSceneStore = create<SceneState>()(
|
||||
}
|
||||
}
|
||||
|
||||
set({
|
||||
nodes: cleanedNodes,
|
||||
rootNodeIds,
|
||||
dirtyNodes: new Set<AnyNodeId>(),
|
||||
collections: {},
|
||||
})
|
||||
|
||||
const normalizedRootNodeIds = normalizeRootNodeIds(cleanedNodes, rootNodeIds)
|
||||
const reachableNodeIds = collectReachableNodeIds(cleanedNodes, normalizedRootNodeIds)
|
||||
if (normalizedRootNodeIds.length > 0) {
|
||||
@@ -579,6 +591,7 @@ const useScene: UseSceneStore = create<SceneState>()(
|
||||
|
||||
createNodes: (ops) => nodeActions.createNodesAction(set, get, ops),
|
||||
createNode: (node, parentId) => nodeActions.createNodesAction(set, get, [{ node, parentId }]),
|
||||
applyNodeChanges: (changes) => nodeActions.applyNodeChangesAction(set, get, changes),
|
||||
|
||||
updateNodes: (updates) => nodeActions.updateNodesAction(set, get, updates),
|
||||
updateNode: (id, data) => nodeActions.updateNodesAction(set, get, [{ id, data }]),
|
||||
@@ -701,8 +714,8 @@ let prevFutureLength = 0
|
||||
let prevNodesSnapshot: Record<AnyNodeId, AnyNode> | null = null
|
||||
|
||||
export function clearSceneHistory() {
|
||||
useScene.temporal.getState().clear()
|
||||
resetSceneHistoryPauseDepth()
|
||||
useScene.temporal.getState().clear()
|
||||
prevPastLength = 0
|
||||
prevFutureLength = 0
|
||||
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 type { AnyNode } from '../../schema'
|
||||
import { BuildingNode, CeilingNode, ElevatorNode, LevelNode, SlabNode } from '../../schema'
|
||||
|
||||
@@ -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 type { AnyNode } from '../../schema'
|
||||
import {
|
||||
|
||||
@@ -35,10 +35,9 @@ type AxisAlignedRect = {
|
||||
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 STAIR_SLAB_OPENING_TIGHTENING = 0
|
||||
const CURVED_STAIR_OPENING_STEP_PADDING = 3
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(max, Math.max(min, value))
|
||||
@@ -280,7 +279,7 @@ function polygonArea(points: Point2D[]) {
|
||||
for (let index = 0; index < points.length; index += 1) {
|
||||
const current = points[index]
|
||||
const next = points[(index + 1) % points.length]
|
||||
if (!current || !next) continue
|
||||
if (!(current && next)) continue
|
||||
area += current[0] * next[1] - next[0] * current[1]
|
||||
}
|
||||
return area / 2
|
||||
@@ -430,39 +429,24 @@ function buildUnionPolygonsFromRects(rects: AxisAlignedRect[]): Point2D[][] {
|
||||
return polygons
|
||||
}
|
||||
|
||||
function getCurvedOpeningStepCount(
|
||||
stair: StairNode,
|
||||
innerRadius: number,
|
||||
outerRadius: number,
|
||||
totalSweep: number,
|
||||
) {
|
||||
const stepCount = Math.max(2, Math.round(stair.stepCount ?? 10))
|
||||
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,
|
||||
function getCurvedOpeningPolygon(stair: StairNode): 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 openingSweep =
|
||||
Math.sign(totalSweep || 1) *
|
||||
Math.max(
|
||||
1,
|
||||
Math.ceil(1.8 / treadDepth),
|
||||
Math.ceil(stepCount * CURVED_STAIR_SLAB_OPENING_RATIO),
|
||||
),
|
||||
Math.abs(totalSweep) * CURVED_STAIR_SLAB_OPENING_RATIO,
|
||||
Math.abs(totalSweep) / Math.max(stair.stepCount ?? 1, 1),
|
||||
)
|
||||
}
|
||||
|
||||
function buildArcOpeningPolygon(
|
||||
stair: StairNode,
|
||||
innerRadius: number,
|
||||
outerRadius: number,
|
||||
startAngle: number,
|
||||
endAngle: number,
|
||||
): Point2D[] {
|
||||
const sweep = endAngle - startAngle
|
||||
const startAngle = totalSweep / 2 - openingSweep
|
||||
const endAngle = totalSweep / 2
|
||||
const segmentCount = Math.max(
|
||||
10,
|
||||
Math.min(
|
||||
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[] = []
|
||||
@@ -470,7 +454,7 @@ function buildArcOpeningPolygon(
|
||||
|
||||
for (let index = 0; index <= segmentCount; index++) {
|
||||
const t = index / segmentCount
|
||||
const angle = startAngle + sweep * t
|
||||
const angle = startAngle + (endAngle - startAngle) * t
|
||||
outerPoints.push(
|
||||
toWorldPlanPoint(stair, Math.cos(angle) * outerRadius, Math.sin(angle) * outerRadius),
|
||||
)
|
||||
@@ -478,8 +462,7 @@ function buildArcOpeningPolygon(
|
||||
|
||||
for (let index = segmentCount; index >= 0; index--) {
|
||||
const t = index / segmentCount
|
||||
const angle = startAngle + sweep * t
|
||||
|
||||
const angle = startAngle + (endAngle - startAngle) * t
|
||||
innerPoints.push(
|
||||
toWorldPlanPoint(stair, Math.cos(angle) * innerRadius, Math.sin(angle) * innerRadius),
|
||||
)
|
||||
@@ -488,39 +471,6 @@ function buildArcOpeningPolygon(
|
||||
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[] {
|
||||
const radius = Math.max(0.05, stair.innerRadius ?? 0.9) + Math.max(stair.width ?? 1, 0.4)
|
||||
const segmentCount = 48
|
||||
@@ -625,7 +575,7 @@ function getStairOpeningPolygons(
|
||||
}
|
||||
|
||||
if (stair.stairType === 'curved') {
|
||||
return [getCurvedOpeningPolygon(stair, targetElevation)]
|
||||
return [getCurvedOpeningPolygon(stair)]
|
||||
}
|
||||
|
||||
if (stair.stairType === 'spiral') {
|
||||
@@ -784,8 +734,7 @@ export function syncAutoStairOpenings(nodes: Record<string, AnyNode>) {
|
||||
]
|
||||
|
||||
if (
|
||||
!polygonsEqual(existingHoles, nextHoles) ||
|
||||
!metadataEqual(existingMetadata, nextMetadata)
|
||||
!(polygonsEqual(existingHoles, nextHoles) && metadataEqual(existingMetadata, nextMetadata))
|
||||
) {
|
||||
updates.push({
|
||||
id: slab.id,
|
||||
@@ -840,8 +789,7 @@ export function syncAutoStairOpenings(nodes: Record<string, AnyNode>) {
|
||||
]
|
||||
|
||||
if (
|
||||
!polygonsEqual(existingHoles, nextHoles) ||
|
||||
!metadataEqual(existingMetadata, nextMetadata)
|
||||
!(polygonsEqual(existingHoles, nextHoles) && metadataEqual(existingMetadata, nextMetadata))
|
||||
) {
|
||||
updates.push({
|
||||
id: ceiling.id,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Point2D } from './wall-mitering'
|
||||
import type { FenceNode, WallNode } from '../../schema'
|
||||
import type { Point2D } from './wall-mitering'
|
||||
|
||||
const CURVE_EPSILON = 1e-6
|
||||
const DEFAULT_SAMPLE_SEGMENTS = 24
|
||||
@@ -115,7 +115,7 @@ function getWallArcData(wall: WallCurveLike) {
|
||||
}
|
||||
|
||||
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 direction = Math.sign(sagitta) || 1
|
||||
const center = {
|
||||
@@ -183,7 +183,10 @@ export function getWallMidpointHandlePoint(wall: WallCurveLike) {
|
||||
|
||||
export function sampleWallCenterline(wall: WallCurveLike, segments = DEFAULT_SAMPLE_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) {
|
||||
|
||||
@@ -135,10 +135,7 @@ function findJunctions(walls: WallNode[]): Map<string, Junction> {
|
||||
return actualJunctions
|
||||
}
|
||||
|
||||
function getWallDirectionFromJunction(
|
||||
wall: WallNode,
|
||||
endType: 'start' | 'end' | 'passthrough',
|
||||
) {
|
||||
function getWallDirectionFromJunction(wall: WallNode, endType: 'start' | 'end' | 'passthrough') {
|
||||
if (endType === 'passthrough') {
|
||||
return {
|
||||
x: wall.end[0] - wall.start[0],
|
||||
@@ -148,9 +145,7 @@ function getWallDirectionFromJunction(
|
||||
|
||||
if (isCurvedWall(wall)) {
|
||||
const frame = getWallCurveFrameAt(wall, endType === 'start' ? 0 : 1)
|
||||
return endType === 'start'
|
||||
? frame.tangent
|
||||
: { x: -frame.tangent.x, y: -frame.tangent.y }
|
||||
return endType === 'start' ? frame.tangent : { x: -frame.tangent.x, y: -frame.tangent.y }
|
||||
}
|
||||
|
||||
return endType === 'start'
|
||||
@@ -158,18 +153,12 @@ function getWallDirectionFromJunction(
|
||||
: { x: wall.start[0] - wall.end[0], y: wall.start[1] - wall.end[1] }
|
||||
}
|
||||
|
||||
function getWallBoundaryFrame(
|
||||
wall: WallNode,
|
||||
endType: 'start' | 'end',
|
||||
) {
|
||||
function getWallBoundaryFrame(wall: WallNode, endType: 'start' | 'end') {
|
||||
if (isCurvedWall(wall)) {
|
||||
const frame = getWallCurveFrameAt(wall, endType === 'start' ? 0 : 1)
|
||||
return {
|
||||
point: frame.point,
|
||||
tangent:
|
||||
endType === 'start'
|
||||
? frame.tangent
|
||||
: { x: -frame.tangent.x, y: -frame.tangent.y },
|
||||
tangent: endType === 'start' ? frame.tangent : { x: -frame.tangent.x, y: -frame.tangent.y },
|
||||
normal: frame.normal,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
import type { WallNode } from '../../schema'
|
||||
|
||||
const AXIS_EPSILON = 1e-6
|
||||
|
||||
export type WallPlanPoint = [number, number]
|
||||
export type WallMoveAxis = 'x' | 'z'
|
||||
export type WallMoveEndpoint = 'start' | 'end'
|
||||
|
||||
export type WallMoveBridgePlan<TWall extends Pick<WallNode, 'id' | 'start' | 'end'>> = {
|
||||
wall: TWall
|
||||
originalPoint: WallPlanPoint
|
||||
movedEndpoint: WallMoveEndpoint
|
||||
}
|
||||
|
||||
export type WallMoveLinkedWallTargetPlan<
|
||||
TWall extends Pick<WallNode, 'id' | 'start' | 'end'>,
|
||||
> = {
|
||||
wall: TWall
|
||||
originalPoint: WallPlanPoint
|
||||
targetPoint: WallPlanPoint
|
||||
}
|
||||
|
||||
export type WallMoveJunctionPlan<TWall extends Pick<WallNode, 'id' | 'start' | 'end'>> = {
|
||||
linkedWallsToMove: TWall[]
|
||||
linkedWallTargetPlans: Array<WallMoveLinkedWallTargetPlan<TWall>>
|
||||
bridgePlans: Array<WallMoveBridgePlan<TWall>>
|
||||
wallsToDelete: TWall[]
|
||||
}
|
||||
|
||||
export function getPerpendicularWallMoveAxis(
|
||||
start: WallPlanPoint,
|
||||
end: WallPlanPoint,
|
||||
): WallMoveAxis | null {
|
||||
const wallDeltaX = Math.abs(end[0] - start[0])
|
||||
const wallDeltaZ = Math.abs(end[1] - start[1])
|
||||
|
||||
if (wallDeltaX < AXIS_EPSILON && wallDeltaZ < AXIS_EPSILON) return null
|
||||
|
||||
return wallDeltaX >= wallDeltaZ ? 'z' : 'x'
|
||||
}
|
||||
|
||||
export function constrainWallMoveDeltaToAxis(
|
||||
deltaX: number,
|
||||
deltaZ: number,
|
||||
axis: WallMoveAxis | null,
|
||||
): WallPlanPoint {
|
||||
if (axis === 'x') return [deltaX, 0]
|
||||
if (axis === 'z') return [0, deltaZ]
|
||||
return [deltaX, deltaZ]
|
||||
}
|
||||
|
||||
function pointsEqual(a: WallPlanPoint, b: WallPlanPoint) {
|
||||
return Math.abs(a[0] - b[0]) <= AXIS_EPSILON && Math.abs(a[1] - b[1]) <= AXIS_EPSILON
|
||||
}
|
||||
|
||||
function wallTouchesPoint(wall: Pick<WallNode, 'start' | 'end'>, point: WallPlanPoint) {
|
||||
return pointsEqual(wall.start, point) || pointsEqual(wall.end, point)
|
||||
}
|
||||
|
||||
function otherWallEndpoint(wall: Pick<WallNode, 'start' | 'end'>, point: WallPlanPoint) {
|
||||
return pointsEqual(wall.start, point) ? wall.end : wall.start
|
||||
}
|
||||
|
||||
type MoveWallRelation = 'same-direction' | 'opposite-direction' | 'off-axis' | 'stationary'
|
||||
type RelatedWallEntry<TWall extends Pick<WallNode, 'id' | 'start' | 'end'>> = {
|
||||
wall: TWall
|
||||
relation: MoveWallRelation
|
||||
}
|
||||
|
||||
function wallLengthFromPoint(wall: Pick<WallNode, 'start' | 'end'>, point: WallPlanPoint) {
|
||||
const freeEndpoint = otherWallEndpoint(wall, point)
|
||||
return Math.hypot(freeEndpoint[0] - point[0], freeEndpoint[1] - point[1])
|
||||
}
|
||||
|
||||
function getMoveWallRelation(
|
||||
wall: Pick<WallNode, 'start' | 'end'>,
|
||||
sharedPoint: WallPlanPoint,
|
||||
nextPoint: WallPlanPoint,
|
||||
): MoveWallRelation {
|
||||
const moveX = nextPoint[0] - sharedPoint[0]
|
||||
const moveZ = nextPoint[1] - sharedPoint[1]
|
||||
const moveLength = Math.hypot(moveX, moveZ)
|
||||
|
||||
if (moveLength < AXIS_EPSILON) return 'stationary'
|
||||
|
||||
const freeEndpoint = otherWallEndpoint(wall, sharedPoint)
|
||||
const wallX = freeEndpoint[0] - sharedPoint[0]
|
||||
const wallZ = freeEndpoint[1] - sharedPoint[1]
|
||||
const wallLength = Math.hypot(wallX, wallZ)
|
||||
|
||||
if (wallLength < AXIS_EPSILON) return 'stationary'
|
||||
|
||||
const normalizedCross = Math.abs(moveX * wallZ - moveZ * wallX) / (moveLength * wallLength)
|
||||
if (normalizedCross > 1e-4) return 'off-axis'
|
||||
|
||||
const normalizedDot = (moveX * wallX + moveZ * wallZ) / (moveLength * wallLength)
|
||||
return normalizedDot >= 0 ? 'same-direction' : 'opposite-direction'
|
||||
}
|
||||
|
||||
export function planWallMoveJunctions<TWall extends Pick<WallNode, 'id' | 'start' | 'end'>>(
|
||||
linkedWalls: TWall[],
|
||||
originalStart: WallPlanPoint,
|
||||
originalEnd: WallPlanPoint,
|
||||
nextStart: WallPlanPoint,
|
||||
nextEnd: WallPlanPoint,
|
||||
): WallMoveJunctionPlan<TWall> {
|
||||
const linkedWallsToMove = new Map<TWall['id'], TWall>()
|
||||
const linkedWallTargetPlans = new Map<TWall['id'], WallMoveLinkedWallTargetPlan<TWall>>()
|
||||
const bridgePlans = new Map<string, WallMoveBridgePlan<TWall>>()
|
||||
const wallsToDelete = new Map<TWall['id'], TWall>()
|
||||
|
||||
const addStandardEndpointPlan = (
|
||||
endpoint: WallMoveEndpoint,
|
||||
point: WallPlanPoint,
|
||||
nextPoint: WallPlanPoint,
|
||||
relatedWalls: Array<RelatedWallEntry<TWall>>,
|
||||
keySuffix = '',
|
||||
useTargetPlans = false,
|
||||
) => {
|
||||
const hasSideBranch = relatedWalls.some((entry) => entry.relation === 'off-axis')
|
||||
const hasOppositeBridge = relatedWalls.some(
|
||||
(entry) => entry.relation === 'opposite-direction' && hasSideBranch,
|
||||
)
|
||||
|
||||
for (const { wall, relation } of relatedWalls) {
|
||||
if (
|
||||
relation === 'stationary' ||
|
||||
relation === 'same-direction' ||
|
||||
(relation === 'opposite-direction' && !hasSideBranch)
|
||||
) {
|
||||
if (useTargetPlans) {
|
||||
linkedWallTargetPlans.set(wall.id, {
|
||||
wall,
|
||||
originalPoint: point,
|
||||
targetPoint: nextPoint,
|
||||
})
|
||||
} else {
|
||||
linkedWallsToMove.set(wall.id, wall)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (relation === 'off-axis' && hasOppositeBridge) {
|
||||
continue
|
||||
}
|
||||
|
||||
bridgePlans.set(`${wall.id}:${endpoint}${keySuffix}`, {
|
||||
wall,
|
||||
originalPoint: point,
|
||||
movedEndpoint: endpoint,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const addEndpointPlan = (
|
||||
endpoint: WallMoveEndpoint,
|
||||
point: WallPlanPoint,
|
||||
nextPoint: WallPlanPoint,
|
||||
) => {
|
||||
const moveLength = Math.hypot(nextPoint[0] - point[0], nextPoint[1] - point[1])
|
||||
const linkedAtEndpoint = linkedWalls
|
||||
.filter((wall) => wallTouchesPoint(wall, point))
|
||||
.map((wall) => ({
|
||||
wall,
|
||||
relation: getMoveWallRelation(wall, point, nextPoint),
|
||||
}))
|
||||
const consumedSameDirectionWall = linkedAtEndpoint
|
||||
.filter((entry) => entry.relation === 'same-direction')
|
||||
.map((entry) => ({
|
||||
...entry,
|
||||
distance: wallLengthFromPoint(entry.wall, point),
|
||||
}))
|
||||
.filter((entry) => moveLength + AXIS_EPSILON >= entry.distance)
|
||||
.sort((a, b) => a.distance - b.distance)[0]
|
||||
|
||||
if (consumedSameDirectionWall) {
|
||||
const pivotPoint = [...otherWallEndpoint(consumedSameDirectionWall.wall, point)] as WallPlanPoint
|
||||
const bridgeSource = linkedAtEndpoint.find((entry) => entry.relation === 'opposite-direction')
|
||||
|
||||
wallsToDelete.set(consumedSameDirectionWall.wall.id, consumedSameDirectionWall.wall)
|
||||
linkedWallTargetPlans.set(consumedSameDirectionWall.wall.id, {
|
||||
wall: consumedSameDirectionWall.wall,
|
||||
originalPoint: point,
|
||||
targetPoint: pivotPoint,
|
||||
})
|
||||
|
||||
if (bridgeSource) {
|
||||
linkedWallTargetPlans.set(bridgeSource.wall.id, {
|
||||
wall: bridgeSource.wall,
|
||||
originalPoint: point,
|
||||
targetPoint: pivotPoint,
|
||||
})
|
||||
|
||||
bridgePlans.set(`${bridgeSource.wall.id}:${endpoint}:through`, {
|
||||
wall: bridgeSource.wall,
|
||||
originalPoint: pivotPoint,
|
||||
movedEndpoint: endpoint,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const linkedAtPivot = linkedWalls
|
||||
.filter(
|
||||
(wall) => wall.id !== consumedSameDirectionWall.wall.id && wallTouchesPoint(wall, pivotPoint),
|
||||
)
|
||||
.map((wall) => ({
|
||||
wall,
|
||||
relation: getMoveWallRelation(wall, pivotPoint, nextPoint),
|
||||
}))
|
||||
|
||||
addStandardEndpointPlan(endpoint, pivotPoint, nextPoint, linkedAtPivot, ':through-pivot', true)
|
||||
return
|
||||
}
|
||||
|
||||
addStandardEndpointPlan(endpoint, point, nextPoint, linkedAtEndpoint)
|
||||
}
|
||||
|
||||
addEndpointPlan('start', originalStart, nextStart)
|
||||
addEndpointPlan('end', originalEnd, nextEnd)
|
||||
|
||||
return {
|
||||
linkedWallsToMove: Array.from(linkedWallsToMove.values()),
|
||||
linkedWallTargetPlans: Array.from(linkedWallTargetPlans.values()),
|
||||
bridgePlans: Array.from(bridgePlans.values()),
|
||||
wallsToDelete: Array.from(wallsToDelete.values()),
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,8 @@
|
||||
"rootDir": "src",
|
||||
"noEmit": false,
|
||||
"composite": true,
|
||||
"incremental": true
|
||||
"incremental": true,
|
||||
"types": ["bun"]
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@pascal-app/editor",
|
||||
"version": "0.6.0",
|
||||
"version": "0.8.0",
|
||||
"description": "Pascal building editor component",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
@@ -11,8 +11,8 @@
|
||||
"check-types": "tsc --noEmit"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@pascal-app/core": "^0.6.0",
|
||||
"@pascal-app/viewer": "^0.6.0",
|
||||
"@pascal-app/core": "^0.8.0",
|
||||
"@pascal-app/viewer": "^0.8.0",
|
||||
"@react-three/drei": "^10",
|
||||
"@react-three/fiber": "^9",
|
||||
"next": ">=15",
|
||||
@@ -44,24 +44,24 @@
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"howler": "^2.2.4",
|
||||
"lucide-react": "^0.562.0",
|
||||
"lucide-react": "^1.7.0",
|
||||
"mitt": "^3.0.1",
|
||||
"motion": "^12.34.3",
|
||||
"nanoid": "^5.1.6",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"three-mesh-bvh": "^0.9.8",
|
||||
"zod": "^4.3.6",
|
||||
"zustand": "^5.0.11"
|
||||
"zustand": "^5.0.11",
|
||||
"three-mesh-bvh": "~0.9.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@pascal-app/core": "^0.6.0",
|
||||
"@pascal-app/viewer": "^0.6.0",
|
||||
"@pascal-app/core": "^0.8.0",
|
||||
"@pascal-app/viewer": "^0.8.0",
|
||||
"@pascal/typescript-config": "*",
|
||||
"@types/bun": "^1.3.0",
|
||||
"@types/howler": "^2.2.12",
|
||||
"@types/node": "^22.19.12",
|
||||
"@types/react": "19.2.2",
|
||||
"@types/react-dom": "19.2.2",
|
||||
"@types/three": "^0.184.0",
|
||||
"typescript": "5.9.3"
|
||||
"typescript": "6.0.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ export type FloorplanActionMenuEntry = {
|
||||
onDelete: FloorplanActionMenuHandler
|
||||
onMove: FloorplanActionMenuHandler
|
||||
onAddHole?: FloorplanActionMenuHandler
|
||||
onCurve?: FloorplanActionMenuHandler
|
||||
onDuplicate?: FloorplanActionMenuHandler
|
||||
}
|
||||
|
||||
@@ -84,6 +85,7 @@ export const FloorplanActionMenuLayer = memo(function FloorplanActionMenuLayer({
|
||||
>
|
||||
<NodeActionMenu
|
||||
onAddHole={entry.onAddHole}
|
||||
onCurve={entry.onCurve}
|
||||
onDelete={entry.onDelete}
|
||||
onDuplicate={entry.onDuplicate}
|
||||
onMove={entry.onMove}
|
||||
|
||||
@@ -72,7 +72,12 @@ export const FloorplanDraftLayer = memo(function FloorplanDraftLayer({
|
||||
)}
|
||||
|
||||
{polygonDraftPolygonPoints && (
|
||||
<polygon fill={draftFill} fillOpacity={0.2} points={polygonDraftPolygonPoints} stroke="none" />
|
||||
<polygon
|
||||
fill={draftFill}
|
||||
fillOpacity={0.2}
|
||||
points={polygonDraftPolygonPoints}
|
||||
stroke="none"
|
||||
/>
|
||||
)}
|
||||
|
||||
{polygonDraftPolylinePoints && (
|
||||
|
||||
@@ -125,7 +125,12 @@ export const FloorplanMeasurementsLayer = memo(function FloorplanMeasurementsLay
|
||||
return (
|
||||
<>
|
||||
{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
|
||||
dashed={measurement.dashedExtensions ?? true}
|
||||
isSelected={measurement.isSelected}
|
||||
|
||||
@@ -396,8 +396,8 @@ export const FloorplanStairLayer = memo(function FloorplanStairLayer({
|
||||
<>
|
||||
<polyline
|
||||
fill="none"
|
||||
points={formatSvgPolygonPoints(arrow.polyline)}
|
||||
pointerEvents="none"
|
||||
points={formatSvgPolygonPoints(arrow.polyline)}
|
||||
stroke={straightAccent}
|
||||
strokeWidth="1.15"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
@@ -411,8 +411,8 @@ export const FloorplanStairLayer = memo(function FloorplanStairLayer({
|
||||
/>
|
||||
<polygon
|
||||
fill={straightAccent}
|
||||
points={formatSvgPolygonPoints(arrow.head)}
|
||||
pointerEvents="none"
|
||||
points={formatSvgPolygonPoints(arrow.head)}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
@@ -438,8 +438,6 @@ export const FloorplanStairLayer = memo(function FloorplanStairLayer({
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onPointerEnter={canSelectStairs ? () => onStairHoverEnter(stair.id) : undefined}
|
||||
onPointerLeave={canSelectStairs ? () => onStairHoverChange(null) : undefined}
|
||||
onPointerDown={
|
||||
canFocusStairs && stairSelected
|
||||
? (event) => {
|
||||
@@ -449,6 +447,8 @@ export const FloorplanStairLayer = memo(function FloorplanStairLayer({
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onPointerEnter={canSelectStairs ? () => onStairHoverEnter(stair.id) : undefined}
|
||||
onPointerLeave={canSelectStairs ? () => onStairHoverChange(null) : undefined}
|
||||
pointerEvents={canSelectStairs ? undefined : 'none'}
|
||||
style={canSelectStairs ? { cursor } : undefined}
|
||||
>
|
||||
@@ -456,8 +456,8 @@ export const FloorplanStairLayer = memo(function FloorplanStairLayer({
|
||||
<polygon
|
||||
fill="transparent"
|
||||
key={`${stair.id}:hit:${polygonIndex}`}
|
||||
points={formatSvgPolygonPoints(polygon)}
|
||||
pointerEvents={canSelectStairs ? 'all' : 'none'}
|
||||
points={formatSvgPolygonPoints(polygon)}
|
||||
stroke="transparent"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={hitStrokeWidth}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
type CameraControlEvent,
|
||||
type CameraControlFitSceneEvent,
|
||||
@@ -6,7 +7,7 @@ import {
|
||||
sceneRegistry,
|
||||
useScene,
|
||||
} 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 { useThree } from '@react-three/fiber'
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
|
||||
Executable → Regular
+4
-3
@@ -205,6 +205,7 @@ export function EditorLayoutV2({
|
||||
viewerContent,
|
||||
overlays,
|
||||
}: EditorLayoutV2Props) {
|
||||
const isCaptureMode = useEditor((s) => s.isCaptureMode)
|
||||
const isMobile = useIsMobile()
|
||||
|
||||
if (isMobile) {
|
||||
@@ -229,7 +230,7 @@ export function EditorLayoutV2({
|
||||
|
||||
{/* Main content: left column + right column */}
|
||||
<div className="flex min-h-0 flex-1">
|
||||
{sidebarTabs.length > 0 && (
|
||||
{!isCaptureMode && sidebarTabs.length > 0 && (
|
||||
<LeftColumn
|
||||
renderTabContent={renderTabContent}
|
||||
sidebarOverlay={sidebarOverlay}
|
||||
@@ -238,8 +239,8 @@ export function EditorLayoutV2({
|
||||
)}
|
||||
<RightColumn
|
||||
overlays={overlays}
|
||||
toolbarLeft={viewerToolbarLeft}
|
||||
toolbarRight={viewerToolbarRight}
|
||||
toolbarLeft={isCaptureMode ? undefined : viewerToolbarLeft}
|
||||
toolbarRight={isCaptureMode ? undefined : viewerToolbarRight}
|
||||
>
|
||||
{viewerContent}
|
||||
</RightColumn>
|
||||
|
||||
@@ -1311,14 +1311,15 @@ export const FirstPersonControls = () => {
|
||||
{controllerStart && (
|
||||
<KeyboardControls map={keyboardMap}>
|
||||
<BVHEcctrl
|
||||
ref={controllerRef}
|
||||
key="first-person-controller"
|
||||
acceleration={26}
|
||||
airDragFactor={0.3}
|
||||
colliderCapsuleArgs={[0.25, 0.8, 4, 8]}
|
||||
colliderMeshes={firstPersonColliderMeshes}
|
||||
collisionCheckIteration={3}
|
||||
collisionPushBackDamping={0.1}
|
||||
collisionPushBackThreshold={0.001}
|
||||
debug={false}
|
||||
deceleration={30}
|
||||
delay={0}
|
||||
fallGravityFactor={4}
|
||||
floatCheckType="BOTH"
|
||||
@@ -1329,14 +1330,13 @@ export const FirstPersonControls = () => {
|
||||
floatSpringK={1200}
|
||||
gravity={9.81}
|
||||
jumpVel={6}
|
||||
key="first-person-controller"
|
||||
maxRunSpeed={5.5}
|
||||
maxSlope={1.2}
|
||||
maxWalkSpeed={4}
|
||||
paused={isElevatorRideLocked}
|
||||
position={controllerStart.position}
|
||||
acceleration={26}
|
||||
airDragFactor={0.3}
|
||||
deceleration={30}
|
||||
ref={controllerRef}
|
||||
/>
|
||||
</KeyboardControls>
|
||||
)}
|
||||
@@ -1408,12 +1408,12 @@ export const FirstPersonOverlay = ({ onExit }: { onExit: () => void }) => {
|
||||
{isLocked && (
|
||||
<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">
|
||||
<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" />
|
||||
<InlineControlHint label="Jump" keyLabel="Space" />
|
||||
<InlineControlHint label="Sprint" keyLabel="Shift" />
|
||||
<InlineControlHint label="Interact" keyLabel="E / R" />
|
||||
<InlineControlHint label="Close" keyLabel="T" />
|
||||
<InlineControlHint keyLabel="Space" label="Jump" />
|
||||
<InlineControlHint keyLabel="Shift" label="Sprint" />
|
||||
<InlineControlHint keyLabel="E / R" label="Interact" />
|
||||
<InlineControlHint keyLabel="T" label="Close" />
|
||||
<div className="h-px w-full bg-border/30" />
|
||||
<span className="text-center text-muted-foreground/60 text-xs">
|
||||
Click to look around
|
||||
@@ -1448,7 +1448,7 @@ function ControlHint({ label, keys }: { label: string; keys: string[] }) {
|
||||
function InlineControlHint({ label, keyLabel }: { label: string; keyLabel: string }) {
|
||||
return (
|
||||
<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}
|
||||
</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">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {
|
||||
getGarageVisibleOpeningRatio,
|
||||
type AnyNodeId,
|
||||
type DoorNode,
|
||||
getGarageVisibleOpeningRatio,
|
||||
isOperationDoorType,
|
||||
sceneRegistry,
|
||||
useInteractive,
|
||||
@@ -97,11 +97,11 @@ function shouldSkipColliderNode(nodeId: string, type: (typeof COLLIDER_NODE_TYPE
|
||||
const node = useScene.getState().nodes[nodeId as AnyNodeId]
|
||||
if (!node || node.type !== 'door') return false
|
||||
|
||||
if (node.openingKind === 'opening') 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) {
|
||||
@@ -273,9 +273,7 @@ export function buildFirstPersonColliderWorldFromRegistry(): FirstPersonCollider
|
||||
}
|
||||
|
||||
const mergedGeometry = mergeGeometries(geometries, false)
|
||||
geometries.forEach((geometry) => {
|
||||
geometry.dispose()
|
||||
})
|
||||
geometries.forEach((geometry) => geometry.dispose())
|
||||
|
||||
if (!mergedGeometry || mergedGeometry.getAttribute('position') == null) {
|
||||
mergedGeometry?.dispose()
|
||||
|
||||
@@ -1,15 +1,8 @@
|
||||
import '../../../three-types'
|
||||
import { TransformControls, useKeyboardControls } from '@react-three/drei'
|
||||
import { useFrame, useThree, type ThreeElements } from '@react-three/fiber'
|
||||
import {
|
||||
Suspense,
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
} from 'react'
|
||||
import { type ThreeElements, useFrame, useThree } from '@react-three/fiber'
|
||||
import type { ReactNode } from 'react'
|
||||
import { forwardRef, Suspense, useCallback, useImperativeHandle, useMemo, useRef } from 'react'
|
||||
import * as THREE from 'three'
|
||||
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 elapsedRef = useRef(0)
|
||||
|
||||
function useIsInsideKeyboardControls() {
|
||||
try {
|
||||
return !!useKeyboardControls()
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const isInsideKeyboardControls = useIsInsideKeyboardControls()
|
||||
const [_, getKeys] = isInsideKeyboardControls ? useKeyboardControls() : [null, null]
|
||||
const [, getKeys] = useKeyboardControls()
|
||||
const presetKeys = {
|
||||
forward: false,
|
||||
backward: false,
|
||||
@@ -222,11 +206,11 @@ const BVHEcctrl = forwardRef<BVHEcctrlApi, EcctrlProps>(
|
||||
const scaledContactRadiusVec = useRef(new THREE.Vector3())
|
||||
const deltaDist = useRef(new THREE.Vector3())
|
||||
const currSlopeAngle = useRef(0)
|
||||
const localMinDistance = useRef(Infinity)
|
||||
const localMinDistance = useRef(Number.POSITIVE_INFINITY)
|
||||
const localClosestPoint = useRef(new THREE.Vector3())
|
||||
const localHitNormal = 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 triHitPoint = 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 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
|
||||
if (idleTime.current > sleepTimeout) isSleeping.current = true
|
||||
} else {
|
||||
@@ -340,7 +330,10 @@ const BVHEcctrl = forwardRef<BVHEcctrlApi, EcctrlProps>(
|
||||
upAxis.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
|
||||
@@ -358,18 +351,33 @@ const BVHEcctrl = forwardRef<BVHEcctrlApi, EcctrlProps>(
|
||||
)
|
||||
currentLinVel.current.add(deltaLinVel.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)
|
||||
}
|
||||
},
|
||||
[acceleration, airDragFactor, counterAccFactor, deceleration, maxRunSpeed, maxWalkSpeed, turnSpeed, characterOrigin],
|
||||
[
|
||||
acceleration,
|
||||
airDragFactor,
|
||||
counterAccFactor,
|
||||
deceleration,
|
||||
maxRunSpeed,
|
||||
maxWalkSpeed,
|
||||
turnSpeed,
|
||||
characterOrigin,
|
||||
],
|
||||
)
|
||||
|
||||
const updateSegmentBBox = useCallback(() => {
|
||||
if (!characterGroupRef.current) return
|
||||
|
||||
characterSegment.current.start.set(0, capsuleLength / 2, 0).add(characterGroupRef.current.position)
|
||||
characterSegment.current.end.set(0, -capsuleLength / 2, 0).add(characterGroupRef.current.position)
|
||||
characterSegment.current.start
|
||||
.set(0, capsuleLength / 2, 0)
|
||||
.add(characterGroupRef.current.position)
|
||||
characterSegment.current.end
|
||||
.set(0, -capsuleLength / 2, 0)
|
||||
.add(characterGroupRef.current.position)
|
||||
|
||||
characterBbox.current
|
||||
.makeEmpty()
|
||||
@@ -394,11 +402,18 @@ const BVHEcctrl = forwardRef<BVHEcctrlApi, EcctrlProps>(
|
||||
|
||||
const collisionCheck = useCallback(
|
||||
(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()
|
||||
localCharacterSegment.current.copy(characterSegment.current).applyMatrix4(collideInvertMatrix.current)
|
||||
localCharacterSegment.current
|
||||
.copy(characterSegment.current)
|
||||
.applyMatrix4(collideInvertMatrix.current)
|
||||
|
||||
scaledContactRadiusVec.current.set(
|
||||
capsuleRadius / contactTempScale.current.x,
|
||||
@@ -445,7 +460,10 @@ const BVHEcctrl = forwardRef<BVHEcctrlApi, EcctrlProps>(
|
||||
contactDepth.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)
|
||||
totalDepth.current += contactDepth.current
|
||||
triangleCount.current += 1
|
||||
@@ -492,14 +510,16 @@ const BVHEcctrl = forwardRef<BVHEcctrlApi, EcctrlProps>(
|
||||
|
||||
const floatingCheck = useCallback(
|
||||
(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)
|
||||
floatInvertMatrix.current.copy(originMatrix).invert()
|
||||
floatNormalInverseMatrix.current.getNormalMatrix(floatInvertMatrix.current)
|
||||
floatNormalMatrix.current.getNormalMatrix(originMatrix)
|
||||
|
||||
localFloatSensorSegment.current.copy(floatSensorSegment.current).applyMatrix4(floatInvertMatrix.current)
|
||||
localFloatSensorSegment.current
|
||||
.copy(floatSensorSegment.current)
|
||||
.applyMatrix4(floatInvertMatrix.current)
|
||||
localFloatSensorBboxExpendPoint.current
|
||||
.copy(floatSensorBboxExpendPoint.current)
|
||||
.applyMatrix4(floatInvertMatrix.current)
|
||||
@@ -517,20 +537,33 @@ const BVHEcctrl = forwardRef<BVHEcctrlApi, EcctrlProps>(
|
||||
localFloatSensorBbox.current.min.addScaledVector(scaledFloatRadiusVec.current, -1)
|
||||
localFloatSensorBbox.current.max.add(scaledFloatRadiusVec.current)
|
||||
|
||||
localMinDistance.current = Infinity
|
||||
localClosestPoint.current.set(Infinity, Infinity, Infinity)
|
||||
localMinDistance.current = Number.POSITIVE_INFINITY
|
||||
localClosestPoint.current.set(
|
||||
Number.POSITIVE_INFINITY,
|
||||
Number.POSITIVE_INFINITY,
|
||||
Number.POSITIVE_INFINITY,
|
||||
)
|
||||
|
||||
mesh.geometry.boundsTree.shapecast({
|
||||
intersectsBounds: (box) => box.intersectsBox(localFloatSensorBbox.current),
|
||||
intersectsTriangle: (tri) => {
|
||||
tri.closestPointToSegment(localFloatSensorSegment.current, triHitPoint.current, segHitPoint.current)
|
||||
localUpAxis.current.copy(upAxis.current).applyMatrix3(floatNormalInverseMatrix.current).normalize()
|
||||
tri.closestPointToSegment(
|
||||
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.divide(scaledFloatRadiusVec.current)
|
||||
|
||||
const totalLengthSq = deltaHit.current.lengthSq()
|
||||
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))
|
||||
|
||||
if (horizontalLength < 1 && verticalLength < 1) {
|
||||
@@ -560,9 +593,14 @@ const BVHEcctrl = forwardRef<BVHEcctrlApi, EcctrlProps>(
|
||||
const handleFloatingResponse = useCallback(
|
||||
(meshes: THREE.Mesh[], jump: boolean, delta: number) => {
|
||||
if (meshes.length === 0) return
|
||||
let shouldJump = jump
|
||||
|
||||
globalMinDistance.current = Infinity
|
||||
globalClosestPoint.current.set(Infinity, Infinity, Infinity)
|
||||
globalMinDistance.current = Number.POSITIVE_INFINITY
|
||||
globalClosestPoint.current.set(
|
||||
Number.POSITIVE_INFINITY,
|
||||
Number.POSITIVE_INFINITY,
|
||||
Number.POSITIVE_INFINITY,
|
||||
)
|
||||
floatHitNormal.current.set(0, 1, 0)
|
||||
isOnGround.current = false
|
||||
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.direction.copy(gravityDir.current)
|
||||
const hits = floatRaycaster.current.intersectObjects(floatRaycastCandidates, false)
|
||||
@@ -582,23 +624,28 @@ const BVHEcctrl = forwardRef<BVHEcctrlApi, EcctrlProps>(
|
||||
if (hit?.point) {
|
||||
globalClosestPoint.current.copy(hit.point)
|
||||
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()
|
||||
currSlopeAngle.current = floatHitNormal.current.angleTo(upAxis.current)
|
||||
|
||||
if (currentDistance < floatHeight + capsuleRadius) {
|
||||
isOnGround.current = true
|
||||
jump = false
|
||||
shouldJump = false
|
||||
}
|
||||
|
||||
if (!jump) {
|
||||
if (!shouldJump) {
|
||||
const displacement = floatHeight + capsuleRadius - currentDistance
|
||||
const velocityOnHitNormal = currentLinVel.current.dot(floatHitNormal.current)
|
||||
const springForce = displacement * floatSpringK
|
||||
@@ -608,7 +655,17 @@ const BVHEcctrl = forwardRef<BVHEcctrlApi, EcctrlProps>(
|
||||
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(() => {
|
||||
@@ -646,8 +703,14 @@ const BVHEcctrl = forwardRef<BVHEcctrlApi, EcctrlProps>(
|
||||
)
|
||||
|
||||
const resetLinVel = useCallback(() => currentLinVel.current.set(0, 0, 0), [])
|
||||
const addLinVel = useCallback((velocity: THREE.Vector3) => currentLinVel.current.add(velocity), [])
|
||||
const setLinVel = useCallback((velocity: THREE.Vector3) => currentLinVel.current.copy(velocity), [])
|
||||
const addLinVel = useCallback(
|
||||
(velocity: THREE.Vector3) => currentLinVel.current.add(velocity),
|
||||
[],
|
||||
)
|
||||
const setLinVel = useCallback(
|
||||
(velocity: THREE.Vector3) => currentLinVel.current.copy(velocity),
|
||||
[],
|
||||
)
|
||||
const setMovement = useCallback((movement: MovementInput) => {
|
||||
if (movement.forward !== undefined) forwardState.current = movement.forward
|
||||
if (movement.backward !== undefined) backwardState.current = movement.backward
|
||||
@@ -682,7 +745,9 @@ const BVHEcctrl = forwardRef<BVHEcctrlApi, EcctrlProps>(
|
||||
debugRaySensorEnd.current?.position.copy(floatSensorSegment.current.end)
|
||||
standPointRef.current?.position.copy(globalClosestPoint.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))
|
||||
inputDirRef.current?.position.copy(characterSegment.current.end)
|
||||
@@ -698,13 +763,13 @@ const BVHEcctrl = forwardRef<BVHEcctrlApi, EcctrlProps>(
|
||||
if (paused || elapsedRef.current < delay) return
|
||||
|
||||
const deltaTime = Math.min(1 / 45, delta) * slowMotionFactor
|
||||
const keys = isInsideKeyboardControls && getKeys ? getKeys() : presetKeys
|
||||
const forward = forwardState.current || keys.forward
|
||||
const backward = backwardState.current || keys.backward
|
||||
const leftward = leftwardState.current || keys.leftward
|
||||
const rightward = rightwardState.current || keys.rightward
|
||||
const run = runState.current || keys.run
|
||||
const jump = jumpState.current || keys.jump
|
||||
const keys = getKeys() ?? presetKeys
|
||||
const forward = forwardState.current || (keys.forward ?? false)
|
||||
const backward = backwardState.current || (keys.backward ?? false)
|
||||
const leftward = leftwardState.current || (keys.leftward ?? false)
|
||||
const rightward = rightwardState.current || (keys.rightward ?? false)
|
||||
const run = runState.current || (keys.run ?? false)
|
||||
const jump = jumpState.current || (keys.jump ?? false)
|
||||
|
||||
setInputDirection({
|
||||
forward,
|
||||
@@ -740,7 +805,7 @@ const BVHEcctrl = forwardRef<BVHEcctrlApi, EcctrlProps>(
|
||||
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<group {...props} ref={characterGroupRef} dispose={null}>
|
||||
<group {...props} dispose={null} ref={characterGroupRef}>
|
||||
{debug && (
|
||||
<mesh ref={characterColliderRef}>
|
||||
<capsuleGeometry args={colliderCapsuleArgs} />
|
||||
@@ -777,8 +842,8 @@ const BVHEcctrl = forwardRef<BVHEcctrlApi, EcctrlProps>(
|
||||
<octahedronGeometry args={[0.1, 0]} />
|
||||
<meshNormalMaterial />
|
||||
</mesh>
|
||||
<arrowHelper ref={inputDirRef} args={[undefined, undefined, undefined, '#00f']} />
|
||||
<arrowHelper ref={moveDirRef} args={[undefined, undefined, undefined, '#f00']} />
|
||||
<arrowHelper args={[undefined, undefined, undefined, '#00f']} ref={inputDirRef} />
|
||||
<arrowHelper args={[undefined, undefined, undefined, '#f00']} ref={moveDirRef} />
|
||||
<mesh ref={standPointRef}>
|
||||
<octahedronGeometry args={[0.12, 0]} />
|
||||
<meshBasicMaterial color="red" opacity={0.2} transparent />
|
||||
|
||||
Executable → Regular
+5
-1
@@ -444,7 +444,11 @@ export function FloatingActionMenu() {
|
||||
? handleDuplicate
|
||||
: undefined
|
||||
}
|
||||
onMove={node && !DELETE_ONLY_TYPES.includes(node.type) ? handleMove : undefined}
|
||||
onMove={
|
||||
node && node.type !== 'wall' && !DELETE_ONLY_TYPES.includes(node.type)
|
||||
? handleMove
|
||||
: undefined
|
||||
}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onPointerUp={(e) => e.stopPropagation()}
|
||||
/>
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
useLiveNodeOverrides,
|
||||
useLiveTransforms,
|
||||
useScene,
|
||||
WallNode as WallNodeSchema,
|
||||
type WallNode,
|
||||
WindowNode,
|
||||
ZoneNode as ZoneNodeSchema,
|
||||
@@ -4213,7 +4214,6 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
|
||||
d={path}
|
||||
fill={isDeleteHovered ? palette.deleteFill : palette.slabFill}
|
||||
fillRule="evenodd"
|
||||
opacity={slabFillOpacity}
|
||||
onClick={
|
||||
canSelectSlabs
|
||||
? (event) => {
|
||||
@@ -4232,9 +4232,10 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
|
||||
}
|
||||
onPointerEnter={canSelectSlabs ? () => onSlabHoverChange(slab.id) : undefined}
|
||||
onPointerLeave={canSelectSlabs ? () => onSlabHoverChange(null) : undefined}
|
||||
opacity={slabFillOpacity}
|
||||
pointerEvents={canSelectSlabs ? undefined : 'none'}
|
||||
style={canSelectSlabs ? { cursor: EDITOR_CURSOR } : undefined}
|
||||
stroke="none"
|
||||
style={canSelectSlabs ? { cursor: EDITOR_CURSOR } : undefined}
|
||||
/>
|
||||
{isSelected && !isDeleteHovered ? (
|
||||
<path
|
||||
@@ -4289,7 +4290,6 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
|
||||
d={path}
|
||||
fill={isDeleteHovered ? palette.deleteFill : palette.ceilingFill}
|
||||
fillRule="evenodd"
|
||||
opacity={ceilingFillOpacity}
|
||||
onClick={
|
||||
canSelectCeilings
|
||||
? (event) => {
|
||||
@@ -4310,9 +4310,10 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
|
||||
canSelectCeilings ? () => onCeilingHoverChange(ceiling.id) : undefined
|
||||
}
|
||||
onPointerLeave={canSelectCeilings ? () => onCeilingHoverChange(null) : undefined}
|
||||
opacity={ceilingFillOpacity}
|
||||
pointerEvents={canSelectCeilings ? undefined : 'none'}
|
||||
style={canSelectCeilings ? { cursor: EDITOR_CURSOR } : undefined}
|
||||
stroke="none"
|
||||
style={canSelectCeilings ? { cursor: EDITOR_CURSOR } : undefined}
|
||||
/>
|
||||
{isSelected && !isDeleteHovered ? (
|
||||
<path
|
||||
@@ -4419,8 +4420,8 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
|
||||
<polygon
|
||||
fill={`url(#${wallSelectionHatchId})`}
|
||||
opacity={1}
|
||||
points={points}
|
||||
pointerEvents="none"
|
||||
points={points}
|
||||
/>
|
||||
) : null}
|
||||
</g>
|
||||
@@ -4548,8 +4549,8 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
|
||||
{canSelectGeometry && (
|
||||
<polygon
|
||||
fill="transparent"
|
||||
points={points}
|
||||
pointerEvents="all"
|
||||
points={points}
|
||||
stroke="transparent"
|
||||
strokeWidth={FLOORPLAN_OPENING_HIT_STROKE_WIDTH}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
@@ -4659,8 +4660,8 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
|
||||
{canSelectGeometry && (
|
||||
<polygon
|
||||
fill="transparent"
|
||||
points={points}
|
||||
pointerEvents="all"
|
||||
points={points}
|
||||
stroke="transparent"
|
||||
strokeWidth={FLOORPLAN_OPENING_HIT_STROKE_WIDTH}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
@@ -5339,8 +5340,8 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
|
||||
{canSelectGeometry && (
|
||||
<polygon
|
||||
fill="transparent"
|
||||
points={points}
|
||||
pointerEvents="all"
|
||||
points={points}
|
||||
stroke="transparent"
|
||||
strokeWidth={FLOORPLAN_OPENING_HIT_STROKE_WIDTH}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
@@ -5411,8 +5412,8 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
|
||||
d={foldingPath}
|
||||
fill="none"
|
||||
stroke={doorStroke}
|
||||
strokeLinejoin="round"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={isSelected || isSelectionHighlighted ? '1.8' : '1.25'}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
@@ -6513,6 +6514,12 @@ function FloorplanItemImage({
|
||||
}) {
|
||||
const resolvedUrl = useResolvedAssetUrl(url)
|
||||
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
|
||||
return (
|
||||
<g
|
||||
@@ -6718,6 +6725,7 @@ const FloorplanNodeLayer = memo(function FloorplanNodeLayer({
|
||||
}
|
||||
points={points}
|
||||
stroke={stroke}
|
||||
strokeOpacity={1}
|
||||
strokeWidth={
|
||||
isSelectionActive ? FLOORPLAN_SELECTED_WALL_STROKE_WIDTH : FLOORPLAN_WALL_STROKE_WIDTH
|
||||
}
|
||||
@@ -6767,8 +6775,8 @@ const FloorplanNodeLayer = memo(function FloorplanNodeLayer({
|
||||
<polygon
|
||||
fill={`url(#${wallSelectionHatchId})`}
|
||||
opacity={1}
|
||||
points={points}
|
||||
pointerEvents="none"
|
||||
points={points}
|
||||
/>
|
||||
) : null}
|
||||
{itemDimensionMeasurements.length > 0 ? (
|
||||
@@ -6858,8 +6866,8 @@ const FloorplanNodeLayer = memo(function FloorplanNodeLayer({
|
||||
<polygon
|
||||
fill={fill}
|
||||
fillOpacity={isDeleteHovered ? 0.82 : 0.92}
|
||||
points={FLOORPLAN_SPAWN_ARROW_POINTS}
|
||||
pointerEvents="none"
|
||||
points={FLOORPLAN_SPAWN_ARROW_POINTS}
|
||||
stroke={stroke}
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={0.055}
|
||||
@@ -7774,6 +7782,11 @@ const FloorplanPolygonHandleLayer = memo(function FloorplanPolygonHandleLayer({
|
||||
y2={endSvg.y}
|
||||
/>
|
||||
<line
|
||||
onPointerDown={
|
||||
onEdgePointerDown
|
||||
? (event) => onEdgePointerDown(nodeId, edgeIndex, event)
|
||||
: undefined
|
||||
}
|
||||
pointerEvents="stroke"
|
||||
stroke="transparent"
|
||||
strokeLinecap="round"
|
||||
@@ -7784,11 +7797,6 @@ const FloorplanPolygonHandleLayer = memo(function FloorplanPolygonHandleLayer({
|
||||
x2={endSvg.x}
|
||||
y1={startSvg.y}
|
||||
y2={endSvg.y}
|
||||
onPointerDown={
|
||||
onEdgePointerDown
|
||||
? (event) => onEdgePointerDown(nodeId, edgeIndex, event)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</g>
|
||||
)
|
||||
@@ -8035,6 +8043,7 @@ export function FloorplanPanel() {
|
||||
const setPhase = useEditor((state) => state.setPhase)
|
||||
const setMovingFenceEndpoint = useEditor((state) => state.setMovingFenceEndpoint)
|
||||
const setMovingNode = useEditor((state) => state.setMovingNode)
|
||||
const setCurvingWall = useEditor((state) => state.setCurvingWall)
|
||||
const movingFenceEndpoint = useEditor((state) => state.movingFenceEndpoint)
|
||||
const structureLayer = useEditor((state) => state.structureLayer)
|
||||
const setStructureLayer = useEditor((state) => state.setStructureLayer)
|
||||
@@ -10004,6 +10013,7 @@ export function FloorplanPanel() {
|
||||
selectedWallEntry,
|
||||
wallCurveDraft,
|
||||
])
|
||||
const canCurveSelectedWall = wallCurveHandles.length > 0
|
||||
const slabVertexHandles = useMemo(() => {
|
||||
if (!shouldShowSlabBoundaryHandles) {
|
||||
return []
|
||||
@@ -10611,7 +10621,7 @@ export function FloorplanPanel() {
|
||||
zoneVertexDragState != null ||
|
||||
isPolygonDraftBuildActive
|
||||
|
||||
if (!hasUserAdjustedViewportRef.current && !transientFloorplanFit) {
|
||||
if (!(hasUserAdjustedViewportRef.current || transientFloorplanFit)) {
|
||||
setViewport((current) =>
|
||||
floorplanViewportEquals(current, fittedViewport) ? current : fittedViewport,
|
||||
)
|
||||
@@ -11864,6 +11874,14 @@ export function FloorplanPanel() {
|
||||
}
|
||||
}, [isItemPlacementPreviewActive, scheduleMovingFloorplanNodeRefresh])
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasPendingItemMeshFootprints) {
|
||||
return
|
||||
}
|
||||
|
||||
scheduleMovingFloorplanNodeRefresh()
|
||||
}, [hasPendingItemMeshFootprints, scheduleMovingFloorplanNodeRefresh])
|
||||
|
||||
// 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
|
||||
// keyboard rotation during placement updates `useLiveTransforms` but emits
|
||||
@@ -11879,14 +11897,6 @@ export function FloorplanPanel() {
|
||||
return unsubscribe
|
||||
}, [isItemPlacementPreviewActive, scheduleMovingFloorplanNodeRefresh])
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasPendingItemMeshFootprints) {
|
||||
return
|
||||
}
|
||||
|
||||
scheduleMovingFloorplanNodeRefresh()
|
||||
}, [hasPendingItemMeshFootprints, scheduleMovingFloorplanNodeRefresh])
|
||||
|
||||
useEffect(() => {
|
||||
if (!(movingNode?.type === 'door' || movingNode?.type === 'window')) {
|
||||
return
|
||||
@@ -14985,6 +14995,57 @@ export function FloorplanPanel() {
|
||||
},
|
||||
[selectedWallEntry, setMovingNode, setSelection],
|
||||
)
|
||||
const duplicateSelectedWall = useCallback(() => {
|
||||
const wall = selectedWallEntry?.wall
|
||||
if (!wall?.parentId) {
|
||||
return
|
||||
}
|
||||
|
||||
sfxEmitter.emit('sfx:item-pick')
|
||||
|
||||
const cloned = structuredClone(wall) as Record<string, unknown>
|
||||
delete cloned.id
|
||||
cloned.children = []
|
||||
cloned.metadata = {
|
||||
...(typeof cloned.metadata === 'object' && cloned.metadata !== null ? cloned.metadata : {}),
|
||||
isNew: true,
|
||||
}
|
||||
|
||||
const temporal = useScene.temporal.getState()
|
||||
temporal.pause()
|
||||
try {
|
||||
const duplicate = WallNodeSchema.parse(cloned)
|
||||
useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)
|
||||
setMovingNode(duplicate)
|
||||
setSelection({ selectedIds: [] })
|
||||
} catch (error) {
|
||||
console.error('Failed to duplicate wall', error)
|
||||
} finally {
|
||||
temporal.resume()
|
||||
}
|
||||
}, [selectedWallEntry, setMovingNode, setSelection])
|
||||
const handleSelectedWallDuplicate = useCallback(
|
||||
(event: ReactMouseEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation()
|
||||
duplicateSelectedWall()
|
||||
},
|
||||
[duplicateSelectedWall],
|
||||
)
|
||||
const handleSelectedWallCurve = useCallback(
|
||||
(event: ReactMouseEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation()
|
||||
|
||||
const wall = selectedWallEntry?.wall
|
||||
if (!(wall && canCurveSelectedWall)) {
|
||||
return
|
||||
}
|
||||
|
||||
sfxEmitter.emit('sfx:item-pick')
|
||||
setCurvingWall(wall)
|
||||
setSelection({ selectedIds: [] })
|
||||
},
|
||||
[canCurveSelectedWall, selectedWallEntry, setCurvingWall, setSelection],
|
||||
)
|
||||
const handleSelectedWallDelete = useCallback(
|
||||
(event: ReactMouseEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation()
|
||||
@@ -17036,9 +17097,17 @@ export function FloorplanPanel() {
|
||||
site,
|
||||
])
|
||||
const hasDuplicatableFloorplanSelection = Boolean(
|
||||
selectedItemEntry || selectedOpeningEntry || selectedStairEntry || selectedRoofEntry,
|
||||
selectedItemEntry ||
|
||||
selectedOpeningEntry ||
|
||||
selectedStairEntry ||
|
||||
selectedRoofEntry ||
|
||||
selectedWallEntry,
|
||||
)
|
||||
const handleDuplicateFloorplanSelection = useCallback(() => {
|
||||
if (selectedWallEntry) {
|
||||
duplicateSelectedWall()
|
||||
return
|
||||
}
|
||||
if (selectedOpeningEntry) {
|
||||
duplicateSelectedOpening()
|
||||
return
|
||||
@@ -17055,6 +17124,7 @@ export function FloorplanPanel() {
|
||||
duplicateSelectedRoof()
|
||||
}
|
||||
}, [
|
||||
duplicateSelectedWall,
|
||||
duplicateSelectedItem,
|
||||
duplicateSelectedOpening,
|
||||
duplicateSelectedRoof,
|
||||
@@ -17063,6 +17133,7 @@ export function FloorplanPanel() {
|
||||
selectedOpeningEntry,
|
||||
selectedRoofEntry,
|
||||
selectedStairEntry,
|
||||
selectedWallEntry,
|
||||
])
|
||||
const activeDraftAnchorPoint =
|
||||
referenceScaleDraft?.start ??
|
||||
@@ -17160,17 +17231,13 @@ export function FloorplanPanel() {
|
||||
onDuplicate: handleSelectedItemDuplicate,
|
||||
onMove: handleSelectedItemMove,
|
||||
}}
|
||||
offsetY={FLOORPLAN_ACTION_MENU_OFFSET_Y}
|
||||
opening={{
|
||||
position: selectedOpeningActionMenuPosition,
|
||||
onDelete: handleSelectedOpeningDelete,
|
||||
onDuplicate: handleSelectedOpeningDuplicate,
|
||||
onMove: handleSelectedOpeningMove,
|
||||
}}
|
||||
spawn={{
|
||||
position: selectedSpawnActionMenuPosition,
|
||||
onDelete: handleSelectedSpawnDelete,
|
||||
onMove: handleSelectedSpawnMove,
|
||||
}}
|
||||
roof={{
|
||||
position: selectedRoofActionMenuPosition,
|
||||
onDelete: handleSelectedRoofDelete,
|
||||
@@ -17185,6 +17252,11 @@ export function FloorplanPanel() {
|
||||
: handleSelectedSlabDelete,
|
||||
onMove: selectedSlabEditingHole ? handleSelectedSlabHoleMove : handleSelectedSlabMove,
|
||||
}}
|
||||
spawn={{
|
||||
position: selectedSpawnActionMenuPosition,
|
||||
onDelete: handleSelectedSpawnDelete,
|
||||
onMove: handleSelectedSpawnMove,
|
||||
}}
|
||||
stair={{
|
||||
position: selectedStairActionMenuPosition,
|
||||
onDelete: handleSelectedStairDelete,
|
||||
@@ -17193,10 +17265,11 @@ export function FloorplanPanel() {
|
||||
}}
|
||||
wall={{
|
||||
position: selectedWallActionMenuPosition,
|
||||
onCurve: canCurveSelectedWall ? handleSelectedWallCurve : undefined,
|
||||
onDelete: handleSelectedWallDelete,
|
||||
onDuplicate: handleSelectedWallDuplicate,
|
||||
onMove: handleSelectedWallMove,
|
||||
}}
|
||||
offsetY={FLOORPLAN_ACTION_MENU_OFFSET_Y}
|
||||
/>
|
||||
|
||||
{referenceScaleDraft && (
|
||||
@@ -17229,7 +17302,7 @@ export function FloorplanPanel() {
|
||||
</div>
|
||||
|
||||
<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
|
||||
</div>
|
||||
<div className="mt-1 font-medium text-sm">
|
||||
@@ -17391,8 +17464,8 @@ export function FloorplanPanel() {
|
||||
<FloorplanGuideLayer
|
||||
activeGuideInteractionGuideId={activeGuideInteractionGuideId}
|
||||
activeGuideInteractionMode={activeGuideInteractionMode}
|
||||
guideUi={guideUi}
|
||||
guides={displayGuides}
|
||||
guideUi={guideUi}
|
||||
isInteractive={canInteractWithGuides}
|
||||
onGuideSelect={handleGuideSelect}
|
||||
onGuideTranslateStart={handleGuideTranslateStart}
|
||||
@@ -17413,6 +17486,8 @@ export function FloorplanPanel() {
|
||||
hoveredSlabId={hoveredSlabId}
|
||||
hoveredWallId={hoveredWallId}
|
||||
isDeleteMode={isDeleteMode}
|
||||
isGuideTraceVisible={isGuideTraceVisible}
|
||||
metersPerUnit={calibratedMetersPerUnit}
|
||||
onCeilingDoubleClick={handleCeilingDoubleClick}
|
||||
onCeilingHoverChange={handleCeilingHoverChange}
|
||||
onCeilingSelect={handleCeilingSelect}
|
||||
@@ -17429,11 +17504,9 @@ export function FloorplanPanel() {
|
||||
openingsPolygons={openingsPolygons}
|
||||
palette={palette}
|
||||
selectedIdSet={selectedIdSet}
|
||||
slabSelectionHatchId={slabSelectionHatchId}
|
||||
slabPolygons={displaySlabPolygons}
|
||||
slabSelectionHatchId={slabSelectionHatchId}
|
||||
unit={unit}
|
||||
metersPerUnit={calibratedMetersPerUnit}
|
||||
isGuideTraceVisible={isGuideTraceVisible}
|
||||
wallPolygons={displayWallPolygons}
|
||||
wallSelectionHatchId={wallSelectionHatchId}
|
||||
/>
|
||||
@@ -17529,8 +17602,8 @@ export function FloorplanPanel() {
|
||||
|
||||
<FloorplanReferenceScaleLayer
|
||||
draft={referenceScaleDraft}
|
||||
guideUi={guideUi}
|
||||
guides={displayGuides}
|
||||
guideUi={guideUi}
|
||||
palette={palette}
|
||||
unit={unit}
|
||||
unitsPerPixel={floorplanUnitsPerPixel}
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
import { ViewerOverlay } from '../../components/viewer-overlay'
|
||||
import { ViewerZoneSystem } from '../../components/viewer-zone-system'
|
||||
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 { useKeyboard } from '../../hooks/use-keyboard'
|
||||
import {
|
||||
@@ -64,8 +65,10 @@ import { Grid } from './grid'
|
||||
import { PresetThumbnailGenerator } from './preset-thumbnail-generator'
|
||||
import { SelectionManager } from './selection-manager'
|
||||
import { SiteEdgeLabels } from './site-edge-labels'
|
||||
import { SnapshotCaptureOverlay } from './snapshot-capture-overlay'
|
||||
import { type SnapshotCameraData, ThumbnailGenerator } from './thumbnail-generator'
|
||||
import { WallMeasurementLabel } from './wall-measurement-label'
|
||||
import { WallMoveSideHandles } from './wall-move-side-handles'
|
||||
|
||||
const CAMERA_CONTROLS_HINT_DISMISSED_STORAGE_KEY = 'editor-camera-controls-hint-dismissed:v1'
|
||||
const DELETE_CURSOR_BADGE_COLOR = '#ef4444'
|
||||
@@ -76,12 +79,12 @@ const PAINT_CURSOR_BADGE_DISABLED_COLOR = '#94a3b8'
|
||||
const PAINT_CURSOR_BADGE_OFFSET_X = 14
|
||||
const PAINT_CURSOR_BADGE_OFFSET_Y = 14
|
||||
const EDITOR_HOVER_STYLES: HoverStyles = {
|
||||
default: { visibleColor: 0x00_aaff, hiddenColor: 0xf3_ff47, strength: 5, pulse: true },
|
||||
delete: { visibleColor: 0xef_4444, hiddenColor: 0x99_1b1b, strength: 6, pulse: false },
|
||||
'paint-ready': { visibleColor: 0xf5_9e0b, hiddenColor: 0xfd_e068, strength: 5, pulse: true },
|
||||
default: { visibleColor: 0x00_aa_ff, hiddenColor: 0xf3_ff_47, strength: 5, pulse: true },
|
||||
delete: { visibleColor: 0xef_44_44, hiddenColor: 0x99_1b_1b, strength: 6, pulse: false },
|
||||
'paint-ready': { visibleColor: 0xf5_9e_0b, hiddenColor: 0xfd_e0_68, strength: 5, pulse: true },
|
||||
'paint-disabled': {
|
||||
visibleColor: 0x94_a3b8,
|
||||
hiddenColor: 0x47_5569,
|
||||
visibleColor: 0x94_a3_b8,
|
||||
hiddenColor: 0x47_55_69,
|
||||
strength: 4,
|
||||
pulse: false,
|
||||
},
|
||||
@@ -462,7 +465,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">
|
||||
<section
|
||||
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">
|
||||
{hints.map((hint) => (
|
||||
@@ -585,6 +588,7 @@ const ViewerSceneContent = memo(function ViewerSceneContent({
|
||||
<>
|
||||
{!isFirstPersonMode && <SelectionManager />}
|
||||
{!(isVersionPreviewMode || isFirstPersonMode) && <BoxSelectTool />}
|
||||
{!(isVersionPreviewMode || isFirstPersonMode) && <WallMoveSideHandles />}
|
||||
{!(isVersionPreviewMode || isFirstPersonMode) && <FloatingActionMenu />}
|
||||
{!(isVersionPreviewMode || isFirstPersonMode) && <FloatingBuildingActionMenu />}
|
||||
{!isFirstPersonMode && <WallMeasurementLabel />}
|
||||
@@ -753,7 +757,7 @@ function PaintCursorLayer({
|
||||
(activePaintMaterial.material !== undefined ||
|
||||
activePaintMaterial.materialPreset !== undefined),
|
||||
)
|
||||
const label = !hasMaterial ? 'Choose material' : `Paint ${activePaintTarget}`
|
||||
const label = hasMaterial ? `Paint ${activePaintTarget}` : 'Choose material'
|
||||
const icon = 'mdi:format-color-fill'
|
||||
|
||||
useLayoutEffect(() => {
|
||||
@@ -786,16 +790,16 @@ function PaintCursorLayer({
|
||||
const ViewerCanvas = memo(function ViewerCanvas({
|
||||
isVersionPreviewMode,
|
||||
isLoading,
|
||||
isFirstPersonMode,
|
||||
hasLoadedInitialScene,
|
||||
showLoader,
|
||||
isFirstPersonMode,
|
||||
onThumbnailCapture,
|
||||
}: {
|
||||
isVersionPreviewMode: boolean
|
||||
isLoading: boolean
|
||||
isFirstPersonMode: boolean
|
||||
hasLoadedInitialScene: boolean
|
||||
showLoader: boolean
|
||||
isFirstPersonMode: boolean
|
||||
onThumbnailCapture?: (blob: Blob, cameraData: SnapshotCameraData) => void
|
||||
}) {
|
||||
const viewMode = useEditor((s) => s.viewMode)
|
||||
@@ -837,7 +841,7 @@ const ViewerCanvas = memo(function ViewerCanvas({
|
||||
window.removeEventListener('pointermove', handlePointerMove)
|
||||
window.removeEventListener('pointerup', handlePointerUp)
|
||||
}
|
||||
}, [setFloorplanPaneRatio])
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
setIsCameraControlsHintVisible(!readCameraControlsHintDismissed())
|
||||
@@ -940,7 +944,9 @@ export default function Editor({
|
||||
commandPaletteEmptyAction,
|
||||
}: EditorProps) {
|
||||
const isFirstPersonMode = useEditor((s) => s.isFirstPersonMode)
|
||||
|
||||
useKeyboard({ isVersionPreviewMode, disabled: isFirstPersonMode })
|
||||
|
||||
const { isLoadingSceneRef } = useAutoSave({
|
||||
onSave,
|
||||
onDirty,
|
||||
@@ -951,8 +957,7 @@ export default function Editor({
|
||||
const [isSceneLoading, setIsSceneLoading] = useState(false)
|
||||
const [hasLoadedInitialScene, setHasLoadedInitialScene] = useState(false)
|
||||
const isPreviewMode = useEditor((s) => s.isPreviewMode)
|
||||
const firstPersonPreviousLevelRef = useRef(useViewer.getState().selection.levelId)
|
||||
const wasFirstPersonModeRef = useRef(isFirstPersonMode)
|
||||
const isCaptureMode = useEditor((s) => s.isCaptureMode)
|
||||
|
||||
const sidebarWidth = useSidebarStore((s) => s.width)
|
||||
const isSidebarCollapsed = useSidebarStore((s) => s.isCollapsed)
|
||||
@@ -970,39 +975,6 @@ export default function Editor({
|
||||
}
|
||||
}, [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)
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
@@ -1064,6 +1036,42 @@ export default function Editor({
|
||||
|
||||
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 = (
|
||||
<Viewer hoverStyles={EDITOR_HOVER_STYLES} selectionManager="default">
|
||||
<ExportManager />
|
||||
@@ -1135,21 +1143,24 @@ export default function Editor({
|
||||
navbarSlot={navbarSlot}
|
||||
overlays={
|
||||
<>
|
||||
<FloatingLevelSelector />
|
||||
{!isVersionPreviewMode && (
|
||||
{!isCaptureMode && <FloatingLevelSelector />}
|
||||
{!(isVersionPreviewMode || isCaptureMode) && (
|
||||
<div className="pointer-events-auto">
|
||||
<ActionMenu />
|
||||
</div>
|
||||
)}
|
||||
{!isVersionPreviewMode && (
|
||||
{!(isVersionPreviewMode || isCaptureMode) && (
|
||||
<div className="pointer-events-auto">
|
||||
<PanelManager />
|
||||
</div>
|
||||
)}
|
||||
{!isCaptureMode && (
|
||||
<div className="pointer-events-auto">
|
||||
<HelperManager />
|
||||
</div>
|
||||
)}
|
||||
{viewerBanner}
|
||||
{projectId ? <SnapshotCaptureOverlay projectId={projectId} /> : null}
|
||||
</>
|
||||
}
|
||||
renderTabContent={renderTabContent}
|
||||
@@ -1159,14 +1170,14 @@ export default function Editor({
|
||||
viewerToolbarLeft={viewerToolbarLeft}
|
||||
viewerToolbarRight={viewerToolbarRight}
|
||||
/>
|
||||
<EditorCommands />
|
||||
<CommandPalette emptyAction={commandPaletteEmptyAction} />
|
||||
{/* 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>
|
||||
)}
|
||||
<EditorCommands />
|
||||
<CommandPalette emptyAction={commandPaletteEmptyAction} />
|
||||
</>
|
||||
)}
|
||||
</PresetsProvider>
|
||||
@@ -1222,6 +1233,12 @@ export default function Editor({
|
||||
<HelperManager />
|
||||
</div>
|
||||
</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>
|
||||
|
||||
Executable → Regular
+9
-3
@@ -343,7 +343,7 @@ function applySingleSurfacePaintPreview(
|
||||
if (node.type === 'ceiling') {
|
||||
const root = getRegisteredMesh(node.id)
|
||||
const overlay = root?.getObjectByName('ceiling-grid') as Mesh | undefined
|
||||
if (!root || !overlay) return null
|
||||
if (!(root && overlay)) return null
|
||||
|
||||
const previewColor =
|
||||
getMaterialPresetByRef(material.materialPreset)?.mapProperties.color ??
|
||||
@@ -1011,7 +1011,6 @@ export const SelectionManager = () => {
|
||||
'roof-segment',
|
||||
'stair',
|
||||
'stair-segment',
|
||||
'spawn',
|
||||
'window',
|
||||
'door',
|
||||
'zone',
|
||||
@@ -1408,6 +1407,7 @@ export const SelectionManager = () => {
|
||||
'roof-segment',
|
||||
'stair',
|
||||
'stair-segment',
|
||||
'spawn',
|
||||
'window',
|
||||
'door',
|
||||
'zone',
|
||||
@@ -1652,6 +1652,7 @@ const EditorOutlinerSync = () => {
|
||||
const previewSelectedIds = useViewer((s) => s.previewSelectedIds)
|
||||
const hoveredId = useViewer((s) => s.hoveredId)
|
||||
const outliner = useViewer((s) => s.outliner)
|
||||
const nodes = useScene((s) => s.nodes)
|
||||
|
||||
useEffect(() => {
|
||||
let idsToHighlight: string[] = []
|
||||
@@ -1688,16 +1689,21 @@ const EditorOutlinerSync = () => {
|
||||
// 2. Sync with the imperative outliner arrays (mutate in place to keep references)
|
||||
outliner.selectedObjects.length = 0
|
||||
for (const id of idsToHighlight) {
|
||||
if (!nodes[id as AnyNodeId]) continue
|
||||
const obj = sceneRegistry.nodes.get(id)
|
||||
if (obj?.parent) outliner.selectedObjects.push(obj)
|
||||
}
|
||||
|
||||
outliner.hoveredObjects.length = 0
|
||||
if (hoveredId) {
|
||||
if (!nodes[hoveredId as AnyNodeId]) {
|
||||
useViewer.setState({ hoveredId: null })
|
||||
} else {
|
||||
const obj = sceneRegistry.nodes.get(hoveredId)
|
||||
if (obj?.parent) outliner.hoveredObjects.push(obj)
|
||||
}
|
||||
}, [phase, previewSelectedIds, selection, hoveredId, outliner])
|
||||
}
|
||||
}, [phase, previewSelectedIds, selection, hoveredId, outliner, nodes])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
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 type { CameraControls } from '@react-three/drei'
|
||||
import { useThree } from '@react-three/fiber'
|
||||
@@ -28,7 +28,6 @@ import { EDITOR_LAYER } from '../../lib/constants'
|
||||
|
||||
const THUMBNAIL_WIDTH = 1920
|
||||
const THUMBNAIL_HEIGHT = 1080
|
||||
const AUTO_SAVE_DELAY = 10_000
|
||||
|
||||
export interface SnapshotCameraData {
|
||||
position: [number, number, number]
|
||||
@@ -49,8 +48,6 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
|
||||
const mainCamera = useThree((state) => state.camera)
|
||||
const controls = useThree((state) => state.controls) as CameraControls | null
|
||||
const isGenerating = useRef(false)
|
||||
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const pendingAutoRef = useRef(false)
|
||||
const onThumbnailCaptureRef = useRef(onThumbnailCapture)
|
||||
|
||||
const thumbnailCameraRef = useRef<THREE.PerspectiveCamera | null>(null)
|
||||
@@ -61,7 +58,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
|
||||
onThumbnailCaptureRef.current = 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(() => {
|
||||
const cam = new THREE.PerspectiveCamera(60, THUMBNAIL_WIDTH / THUMBNAIL_HEIGHT, 0.1, 1000)
|
||||
cam.layers.disable(EDITOR_LAYER)
|
||||
@@ -75,7 +72,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
|
||||
if (!mounted) return
|
||||
|
||||
// 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.
|
||||
const scenePass = pass(scene, cam)
|
||||
scenePass.setMRT(
|
||||
@@ -117,15 +114,15 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
|
||||
const ao = (denoisePass as any).r
|
||||
const finalOutput = vec4(scenePassColor.rgb.mul(ao), scenePassColor.a)
|
||||
|
||||
// FXAA requires a texture node as input, convertToTexture renders finalOutput
|
||||
// into an intermediate RT so FXAA can sample it with neighbor UV offsets.
|
||||
// FXAA requires a texture node as input; convertToTexture renders finalOutput
|
||||
// into an intermediate RT so FXAA can sample it with neighbour UV offsets.
|
||||
const aaOutput = fxaa(convertToTexture(finalOutput))
|
||||
|
||||
const pipeline = new RenderPipeline(gl as unknown as WebGPURenderer)
|
||||
pipeline.outputNode = aaOutput
|
||||
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.
|
||||
const { width, height } = gl.domElement
|
||||
renderTargetRef.current = new RenderTarget(width, height, { depthBuffer: true })
|
||||
@@ -176,7 +173,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
|
||||
thumbnailCamera.aspect = width / height
|
||||
thumbnailCamera.updateProjectionMatrix()
|
||||
|
||||
// Capture camera data for snapshot storage.
|
||||
// Capture camera data for snapshot storage
|
||||
const pos = mainCamera.position
|
||||
let tgt: [number, number, number] | null = null
|
||||
if (controls && 'getTarget' in controls) {
|
||||
@@ -192,7 +189,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
|
||||
...(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 restoreLevels: () => void = () => {}
|
||||
if (snapLevels) {
|
||||
@@ -205,7 +202,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
|
||||
}
|
||||
|
||||
// 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.
|
||||
const restoreNodeVisibility = (() => {
|
||||
const saved = new Map<THREE.Object3D, boolean>()
|
||||
@@ -231,7 +228,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
|
||||
if (pipelineRef.current && 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) {
|
||||
rt.setSize(width, height)
|
||||
}
|
||||
@@ -248,7 +245,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
|
||||
emitter.emit('thumbnail:after-capture', undefined)
|
||||
|
||||
// Restore level positions, levelMode, and node visibility immediately after the
|
||||
// render, before the async GPU readback.
|
||||
// render — before the async GPU readback.
|
||||
restoreLevels()
|
||||
restoreLevelMode?.()
|
||||
restoreNodeVisibility()
|
||||
@@ -339,6 +336,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
|
||||
offscreen.getContext('2d')!.drawImage(srcCanvas, sx, sy, outW, outH, 0, 0, outW, outH)
|
||||
blob = await offscreen.convertToBlob({ type: 'image/png' })
|
||||
} else {
|
||||
// Standard: center-crop to 1920×1080 aspect ratio
|
||||
const srcAspect = width / height
|
||||
const dstAspect = THUMBNAIL_WIDTH / THUMBNAIL_HEIGHT
|
||||
let sx = 0,
|
||||
@@ -364,7 +362,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
|
||||
if (captureMode !== undefined) cameraData.captureMode = captureMode
|
||||
cameraData.resolution = { w: outW, h: outH }
|
||||
} else {
|
||||
// Fallback: plain render directly to the canvas.
|
||||
// Fallback: plain render directly to the canvas
|
||||
emitter.emit('thumbnail:before-capture', undefined)
|
||||
gl.render(scene, thumbnailCamera)
|
||||
emitter.emit('thumbnail:after-capture', undefined)
|
||||
@@ -450,10 +448,11 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
|
||||
)
|
||||
|
||||
// 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.
|
||||
// - auto-save capture: `{ projectId, snapLevels: true }`, snaps levels to
|
||||
// their true positions first for a consistent auto-thumbnail angle.
|
||||
// - host-driven auto-save: `{ projectId, snapLevels: true }` — snaps levels
|
||||
// to their true positions first for a consistent auto-thumbnail angle.
|
||||
// The caller owns policy (when to fire, whether the tab is visible).
|
||||
useEffect(() => {
|
||||
if (!onThumbnailCapture) return
|
||||
|
||||
@@ -469,49 +468,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
|
||||
return () => emitter.off('camera-controls:generate-thumbnail', handleGenerateThumbnail)
|
||||
}, [generate, onThumbnailCapture])
|
||||
|
||||
// OSS adaptation: keep local debounced auto-capture behavior because the
|
||||
// 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.
|
||||
// Go-to-camera: animate camera to a saved snapshot position/target
|
||||
useEffect(() => {
|
||||
const handler = ({
|
||||
position,
|
||||
|
||||
@@ -144,11 +144,11 @@ export function useFloorplanBackgroundPlacement({
|
||||
emitFloorplanGridEvent('click', snappedPoint, event)
|
||||
setCursorPoint(snappedPoint)
|
||||
|
||||
if (!roofDraftStart) {
|
||||
if (roofDraftStart) {
|
||||
clearRoofPlacementDraft()
|
||||
} else {
|
||||
setRoofDraftStart(snappedPoint)
|
||||
setRoofDraftEnd(snappedPoint)
|
||||
} else {
|
||||
clearRoofPlacementDraft()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
Executable → Regular
@@ -0,0 +1,259 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
type AnyNodeId,
|
||||
DEFAULT_WALL_HEIGHT,
|
||||
getWallThickness,
|
||||
sceneRegistry,
|
||||
useScene,
|
||||
type WallNode,
|
||||
} from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { createPortal, type ThreeEvent } from '@react-three/fiber'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import {
|
||||
BufferGeometry,
|
||||
ConeGeometry,
|
||||
CylinderGeometry,
|
||||
DoubleSide,
|
||||
Float32BufferAttribute,
|
||||
type Object3D,
|
||||
} from 'three'
|
||||
import { sfxEmitter } from '../../lib/sfx-bus'
|
||||
import useEditor from '../../store/use-editor'
|
||||
|
||||
const HANDLE_OFFSET = 0.42
|
||||
const HANDLE_MIN_OFFSET = 0.5
|
||||
const HANDLE_MIN_HEIGHT = 0.62
|
||||
const HANDLE_TOP_INSET = 0.08
|
||||
const ARROW_COLOR = '#8381ed'
|
||||
const ARROW_HOVER_COLOR = '#a5b4fc'
|
||||
|
||||
type WallMoveHandle = {
|
||||
direction: [number, number]
|
||||
key: string
|
||||
position: [number, number, number]
|
||||
rotationY: number
|
||||
}
|
||||
|
||||
function createArrowHandleGeometry() {
|
||||
const shaft = new CylinderGeometry(0.04, 0.064, 0.25, 36)
|
||||
const head = new ConeGeometry(0.13, 0.3, 48)
|
||||
shaft.rotateZ(-Math.PI / 2)
|
||||
shaft.translate(-0.085, 0, 0)
|
||||
head.rotateZ(-Math.PI / 2)
|
||||
head.translate(0.17, 0, 0)
|
||||
|
||||
const positions: number[] = []
|
||||
const normals: number[] = []
|
||||
const uvs: number[] = []
|
||||
|
||||
for (const sourceGeometry of [shaft, head]) {
|
||||
const geometry = sourceGeometry.index ? sourceGeometry.toNonIndexed() : sourceGeometry
|
||||
const position = geometry.getAttribute('position')
|
||||
const normal = geometry.getAttribute('normal')
|
||||
const uv = geometry.getAttribute('uv')
|
||||
|
||||
for (let index = 0; index < position.count; index += 1) {
|
||||
positions.push(position.getX(index), position.getY(index), position.getZ(index))
|
||||
normals.push(normal.getX(index), normal.getY(index), normal.getZ(index))
|
||||
uvs.push(uv?.getX(index) ?? 0, uv?.getY(index) ?? 0)
|
||||
}
|
||||
|
||||
if (geometry !== sourceGeometry) {
|
||||
geometry.dispose()
|
||||
}
|
||||
sourceGeometry.dispose()
|
||||
}
|
||||
|
||||
const geometry = new BufferGeometry()
|
||||
geometry.setAttribute('position', new Float32BufferAttribute(positions, 3))
|
||||
geometry.setAttribute('normal', new Float32BufferAttribute(normals, 3))
|
||||
geometry.setAttribute('uv', new Float32BufferAttribute(uvs, 2))
|
||||
geometry.setAttribute('uv2', new Float32BufferAttribute([...uvs], 2))
|
||||
geometry.computeVertexNormals()
|
||||
geometry.computeBoundingSphere()
|
||||
return geometry
|
||||
}
|
||||
|
||||
export function WallMoveSideHandles() {
|
||||
const selectedIds = useViewer((state) => state.selection.selectedIds)
|
||||
const mode = useEditor((state) => state.mode)
|
||||
const isFloorplanHovered = useEditor((state) => state.isFloorplanHovered)
|
||||
const movingNode = useEditor((state) => state.movingNode)
|
||||
const movingWallEndpoint = useEditor((state) => state.movingWallEndpoint)
|
||||
const movingFenceEndpoint = useEditor((state) => state.movingFenceEndpoint)
|
||||
const curvingWall = useEditor((state) => state.curvingWall)
|
||||
const curvingFence = useEditor((state) => state.curvingFence)
|
||||
|
||||
const selectedId = selectedIds.length === 1 ? selectedIds[0] : null
|
||||
const wall = useScene((state) => {
|
||||
const node = selectedId ? state.nodes[selectedId as AnyNodeId] : null
|
||||
return node?.type === 'wall' ? node : null
|
||||
})
|
||||
|
||||
const shouldRender =
|
||||
Boolean(wall) &&
|
||||
!isFloorplanHovered &&
|
||||
mode !== 'delete' &&
|
||||
!movingNode &&
|
||||
!movingWallEndpoint &&
|
||||
!movingFenceEndpoint &&
|
||||
!curvingWall &&
|
||||
!curvingFence
|
||||
|
||||
if (!shouldRender || !wall) return null
|
||||
|
||||
return <WallMoveSideHandlesForWall wall={wall} />
|
||||
}
|
||||
|
||||
function WallMoveSideHandlesForWall({ wall }: { wall: WallNode }) {
|
||||
const [levelObject, setLevelObject] = useState<Object3D | null>(() =>
|
||||
wall.parentId ? (sceneRegistry.nodes.get(wall.parentId) ?? null) : null,
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
let frameId = 0
|
||||
|
||||
const resolveLevelObject = () => {
|
||||
const nextLevelObject = wall.parentId
|
||||
? (sceneRegistry.nodes.get(wall.parentId) ?? null)
|
||||
: null
|
||||
setLevelObject((currentLevelObject) => {
|
||||
if (currentLevelObject === nextLevelObject) {
|
||||
return currentLevelObject
|
||||
}
|
||||
return nextLevelObject
|
||||
})
|
||||
|
||||
if (!nextLevelObject) {
|
||||
frameId = window.requestAnimationFrame(resolveLevelObject)
|
||||
}
|
||||
}
|
||||
|
||||
resolveLevelObject()
|
||||
|
||||
return () => {
|
||||
if (frameId) {
|
||||
window.cancelAnimationFrame(frameId)
|
||||
}
|
||||
}
|
||||
}, [wall.parentId])
|
||||
|
||||
const handles = useMemo(() => getWallMoveHandles(wall), [wall])
|
||||
|
||||
if (!levelObject || handles.length === 0) return null
|
||||
|
||||
return createPortal(
|
||||
<group>
|
||||
{handles.map((handle) => (
|
||||
<WallMoveArrowHandle handle={handle} key={handle.key} wall={wall} />
|
||||
))}
|
||||
</group>,
|
||||
levelObject,
|
||||
)
|
||||
}
|
||||
|
||||
function WallMoveArrowHandle({ wall, handle }: { wall: WallNode; handle: WallMoveHandle }) {
|
||||
const [isHovered, setIsHovered] = useState(false)
|
||||
const arrowGeometry = useMemo(() => createArrowHandleGeometry(), [])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (document.body.style.cursor === 'grab' || document.body.style.cursor === 'grabbing') {
|
||||
document.body.style.cursor = ''
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => () => arrowGeometry.dispose(), [arrowGeometry])
|
||||
|
||||
const activateWallMove = (event: ThreeEvent<PointerEvent>) => {
|
||||
event.stopPropagation()
|
||||
event.nativeEvent.preventDefault()
|
||||
document.body.style.cursor = 'grabbing'
|
||||
|
||||
sfxEmitter.emit('sfx:item-pick')
|
||||
useEditor.getState().setMovingNode(wall)
|
||||
useEditor.getState().setMovingWallEndpoint(null)
|
||||
useEditor.getState().setMovingFenceEndpoint(null)
|
||||
useEditor.getState().setCurvingWall(null)
|
||||
useEditor.getState().setCurvingFence(null)
|
||||
useViewer.getState().setSelection({ selectedIds: [] })
|
||||
}
|
||||
|
||||
return (
|
||||
<group
|
||||
position={handle.position}
|
||||
rotation={[0, handle.rotationY, 0]}
|
||||
scale={isHovered ? 1.12 : 1}
|
||||
>
|
||||
<mesh
|
||||
frustumCulled={false}
|
||||
onPointerDown={activateWallMove}
|
||||
onPointerEnter={(event) => {
|
||||
event.stopPropagation()
|
||||
setIsHovered(true)
|
||||
document.body.style.cursor = 'grab'
|
||||
}}
|
||||
onPointerLeave={(event) => {
|
||||
event.stopPropagation()
|
||||
setIsHovered(false)
|
||||
if (document.body.style.cursor === 'grab') {
|
||||
document.body.style.cursor = ''
|
||||
}
|
||||
}}
|
||||
renderOrder={1002}
|
||||
>
|
||||
<primitive attach="geometry" object={arrowGeometry} />
|
||||
<meshBasicMaterial
|
||||
color={isHovered ? ARROW_HOVER_COLOR : ARROW_COLOR}
|
||||
depthTest
|
||||
depthWrite
|
||||
opacity={1}
|
||||
side={DoubleSide}
|
||||
transparent={false}
|
||||
/>
|
||||
</mesh>
|
||||
</group>
|
||||
)
|
||||
}
|
||||
|
||||
function getWallMoveHandles(wall: WallNode): WallMoveHandle[] {
|
||||
const dx = wall.end[0] - wall.start[0]
|
||||
const dz = wall.end[1] - wall.start[1]
|
||||
const length = Math.hypot(dx, dz)
|
||||
|
||||
if (length < 1e-6) {
|
||||
return []
|
||||
}
|
||||
|
||||
const normal: [number, number] = [-dz / length, dx / length]
|
||||
const midpoint: [number, number] = [
|
||||
(wall.start[0] + wall.end[0]) / 2,
|
||||
(wall.start[1] + wall.end[1]) / 2,
|
||||
]
|
||||
const wallHeight = wall.height ?? DEFAULT_WALL_HEIGHT
|
||||
const handleHeight = Math.max(wallHeight - HANDLE_TOP_INSET, HANDLE_MIN_HEIGHT)
|
||||
const offset = Math.max(getWallThickness(wall) / 2 + HANDLE_OFFSET, HANDLE_MIN_OFFSET)
|
||||
|
||||
return [
|
||||
buildWallMoveHandle('front', midpoint, normal, offset, handleHeight),
|
||||
buildWallMoveHandle('back', midpoint, [-normal[0], -normal[1]], offset, handleHeight),
|
||||
]
|
||||
}
|
||||
|
||||
function buildWallMoveHandle(
|
||||
key: string,
|
||||
midpoint: [number, number],
|
||||
direction: [number, number],
|
||||
offset: number,
|
||||
height: number,
|
||||
): WallMoveHandle {
|
||||
return {
|
||||
direction,
|
||||
key,
|
||||
position: [midpoint[0] + direction[0] * offset, height, midpoint[1] + direction[1] * offset],
|
||||
rotationY: Math.atan2(-direction[1], direction[0]),
|
||||
}
|
||||
}
|
||||
@@ -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'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>
|
||||
)
|
||||
}
|
||||
+10
-12
@@ -75,7 +75,9 @@ const CeilingSelectionAffordance = ({
|
||||
ceiling: CeilingNode
|
||||
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])
|
||||
|
||||
@@ -110,11 +112,7 @@ const CeilingSelectionAffordance = ({
|
||||
return createPortal(
|
||||
<group position={[0, (ceiling.height ?? 2.5) + BRACKET_Y_OFFSET, 0]}>
|
||||
{corners.map((corner, index) => (
|
||||
<CornerBracket
|
||||
ceiling={ceiling}
|
||||
corner={corner}
|
||||
key={`${ceiling.id}-corner-${index}`}
|
||||
/>
|
||||
<CornerBracket ceiling={ceiling} corner={corner} key={`${ceiling.id}-corner-${index}`} />
|
||||
))}
|
||||
</group>,
|
||||
levelObject,
|
||||
@@ -210,11 +208,7 @@ const BracketLeg = ({
|
||||
]
|
||||
|
||||
return (
|
||||
<mesh
|
||||
onClick={onClick}
|
||||
position={position}
|
||||
rotation={[0, angle, 0]}
|
||||
>
|
||||
<mesh onClick={onClick} position={position} rotation={[0, angle, 0]}>
|
||||
<boxGeometry args={[length, BRACKET_HEIGHT, BRACKET_THICKNESS]} />
|
||||
<meshBasicMaterial color={color} depthWrite={false} opacity={opacity} transparent />
|
||||
</mesh>
|
||||
@@ -234,7 +228,11 @@ function buildCornerBrackets(polygon: Array<[number, number]>): CornerBracketDat
|
||||
|
||||
const incomingLength = Math.hypot(incomingVector[0], incomingVector[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 {
|
||||
corner,
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useEffect, useRef } from 'react'
|
||||
* Imperatively toggles the Three.js visibility of roof objects based on the
|
||||
* 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
|
||||
* - segments-wrapper group is shown (individual segments visible for editing)
|
||||
* - all children are marked dirty so RoofSystem rebuilds their geometry
|
||||
|
||||
@@ -68,7 +68,7 @@ export const StairEditSystem = () => {
|
||||
const segmentsWrapper = group.getObjectByName('segments-wrapper')
|
||||
const isActive = activeStairIds.has(stairId)
|
||||
|
||||
if (mergedMesh) mergedMesh.visible = !isActive && !isCurved
|
||||
if (mergedMesh) mergedMesh.visible = !(isActive || isCurved)
|
||||
if (segmentsWrapper) segmentsWrapper.visible = isActive && !isCurved
|
||||
|
||||
if (stairNode?.children?.length) {
|
||||
|
||||
Executable → Regular
Executable → Regular
@@ -1,13 +1,19 @@
|
||||
'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 { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { BufferGeometry, DoubleSide, Path, Shape, ShapeGeometry, Vector3 } from 'three'
|
||||
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
import useEditor from '../../../store/use-editor'
|
||||
import { CursorSphere } from '../shared/cursor-sphere'
|
||||
import { BufferGeometry, DoubleSide, Path, Shape, ShapeGeometry, Vector3 } from 'three'
|
||||
|
||||
function snap(value: number) {
|
||||
return Math.round(value * 2) / 2
|
||||
@@ -202,6 +208,7 @@ export const MoveCeilingTool: React.FC<{ node: CeilingNode }> = ({ node }) => {
|
||||
transparent
|
||||
/>
|
||||
</mesh>
|
||||
{/* @ts-ignore */}
|
||||
<line geometry={previewOutlineGeometry} position={[0, (node.height ?? 2.5) + 0.02, 0]}>
|
||||
<lineBasicMaterial color="#ffffff" depthWrite={false} opacity={0.95} transparent />
|
||||
</line>
|
||||
|
||||
@@ -83,8 +83,7 @@ export const CurveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
|
||||
? event.localPosition[2]
|
||||
: snapScalarToGrid(event.localPosition[2], snapStep)
|
||||
|
||||
const offsetFromMidpoint =
|
||||
-(
|
||||
const offsetFromMidpoint = -(
|
||||
(localX - chord.midpoint.x) * chord.normal.x +
|
||||
(localZ - chord.midpoint.y) * chord.normal.y
|
||||
)
|
||||
|
||||
@@ -297,7 +297,7 @@ export const FenceTool: React.FC = () => {
|
||||
|
||||
return (
|
||||
<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}>
|
||||
<shapeGeometry />
|
||||
<meshBasicMaterial
|
||||
@@ -338,7 +338,7 @@ function DraftMeasurementLabel({
|
||||
}) {
|
||||
return (
|
||||
<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}
|
||||
</div>
|
||||
</Html>
|
||||
|
||||
@@ -291,8 +291,9 @@ export const MoveFenceEndpointTool: React.FC<{ target: MovingFenceEndpoint }> =
|
||||
}
|
||||
|
||||
const preview = previewRef.current ?? { start: originalStart, end: originalEnd }
|
||||
const hasChanged =
|
||||
!samePoint(preview.start, originalStart) || !samePoint(preview.end, originalEnd)
|
||||
const hasChanged = !(
|
||||
samePoint(preview.start, originalStart) && samePoint(preview.end, originalEnd)
|
||||
)
|
||||
|
||||
if (hasChanged && isWallLongEnough(preview.start, preview.end)) {
|
||||
wasCommitted = true
|
||||
@@ -392,7 +393,7 @@ export const MoveFenceEndpointTool: React.FC<{ target: MovingFenceEndpoint }> =
|
||||
>
|
||||
<div className="translate-y-10">
|
||||
<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
|
||||
? 'border-amber-500/70 bg-amber-500/15 text-amber-100'
|
||||
: 'border-border/70 bg-background/90 text-foreground/80'
|
||||
@@ -416,7 +417,7 @@ function EndpointAngleLabel({
|
||||
}) {
|
||||
return (
|
||||
<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}
|
||||
</div>
|
||||
</Html>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user