Merge commit 'c3faeba1c8c1275c156c8739b50b8f0ca9425fd3' as 'services/pascal-editor'

This commit is contained in:
134 2026-07-12 00:43:59 +08:00
commit 40ff3ca851
2138 changed files with 444001 additions and 0 deletions

View File

@ -0,0 +1,132 @@
---
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 number,url,title,body 2>/dev/null
```
### 3a. No existing PR → create one
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:`).
### 3b. PR already exists → update its description
Do **not** recreate the PR. Refresh the existing body so it reflects everything currently on the branch, while keeping the template structure and any reviewer-meaningful state the user already set.
1. Capture the existing body and the full branch history:
```bash
gh pr view --json number,body -q '.number, .body' > /tmp/existing-pr.txt
git log --oneline main..HEAD
git diff --stat main..HEAD
```
2. Reconstruct the body section-by-section. Keep the four template headings in the same order (`## What does this PR do?`, `## How to test`, `## Screenshots / screen recording`, `## Checklist`). For each section:
- **What does this PR do?** — rewrite from the *current* commits and diff on the branch, not from memory. Preserve any `Fixes #123` / `Refs #123` lines from the old body.
- **How to test** — regenerate concrete steps for the *current* behaviour. If a previous step is still valid, keep its wording; drop steps that no longer apply; add steps for new commits.
- **Screenshots / screen recording** — preserve the existing content verbatim (links, embedded images, "N/A — …"). Do not blank it out. Only change it if the user explicitly provided a new recording.
- **Checklist** — preserve the user's tick state (`[x]` vs `[ ]`) for every item that still exists in the template. Add any new template items as unchecked.
If the old body contains extra sections the template doesn't have (e.g. a manual "Notes" block), keep them at the end.
3. Apply the update:
```bash
gh pr edit <number> --body "$(cat <<'EOF'
## What does this PR do?
…
EOF
)"
```
Use `gh pr edit --title` *only* if the branch's scope has clearly changed; otherwise leave the title alone.
4. Print the PR URL so the user can confirm the edit.
## 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

View File

@ -0,0 +1,178 @@
---
name: review-architecture
description: Review a PR against the Pascal architectural rules — package boundaries (core/viewer/editor/nodes), the registry-driven composition model (def.geometry / def.renderer / def.system), legacy-dispatch regressions, the slots + world-scale-UV convention for new nodes/geometry, 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:
- `wiki/architecture/layers.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/` or `packages/nodes/src/<kind>/`
- `wiki/architecture/viewer-isolation.md` — viewer must stay editor-agnostic
- `wiki/architecture/node-definitions.md` — the three-checkbox composition model (`geometry` / `renderer` / `system`)
- `wiki/architecture/plugin-authoring.md` — public contract for external node packs
Required on every review. Read the remaining pages on demand when the diff touches their subject area:
- `wiki/architecture/selection-managers.md`
- `wiki/architecture/scene-registry.md`
- `wiki/architecture/spatial-queries.md`
- `wiki/architecture/node-schemas.md`
- `wiki/architecture/events.md`
- `wiki/architecture/interaction-scope.md` — the interaction state machine + the unified snapping/modifier convention. Read whenever the diff touches a tool, a `move-tool` / `selection` / endpoint / reshape file, `lib/interaction/**`, `lib/snapping-mode.ts`, or `use-interaction-scope`.
If anything in the diff looks like a new dispatch surface or registry concept, also skim the live charter at `plans/editor-node-registry.md` (in the private-editor repo) — it owns the current contract and which kind sits at which migration stage.
## 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 package does this belong to — `core`, `viewer`, `editor`, or `nodes`?** If the answer is "editor" but the code lives in `packages/core` or `packages/viewer` (or vice versa), or if kind-specific code lands anywhere other than `packages/nodes/src/<kind>/`, 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 four packages 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`, the registry primitives (`nodeRegistry`, `registerNode`, `loadPlugin`, `discoverPlugins`/`setPluginDiscovery`, `SceneApi`, `Plugin`/`NodeDefinition` types). Consumed by every downstream package, including read-only embeds. Must not know about: Three.js/R3F, `packages/viewer`, `apps/editor`, `packages/nodes`, any rendering or UI concept, any tool/mode/phase concept, or any *view*-specific concept (floorplan, paint preview, cursor indicators, selection outline styling).
**`packages/viewer` — the 3D canvas, shippable standalone.**
Owns: `<Viewer>`, the generic `<NodeRenderer>` / `<ParametricNodeRenderer>` / `<GeometrySystem>` / `<RegisteredSystems>` / `<FloorplanRegistryLayer>` plumbing, 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), `useNodeEvents`. 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), `packages/nodes`.
**`packages/editor` (and `apps/editor`) — the editing experience.**
Owns: the tool framework (`useDragAction`, `ParametricInspector`, `<MoveRegistryNodeTool>`, the registry-aware dispatchers in `tool-manager.tsx` / `MoveTool` / `panel-manager.tsx` / `helper-manager.tsx`), `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. Must not import from `packages/nodes`.
**`packages/nodes` — the built-in plugin (`pascal:core`).**
Owns: one folder per node kind (`packages/nodes/src/<kind>/`) containing `definition.ts`, `schema.ts`, optionally `geometry.ts` / `renderer.tsx` / `system.tsx` / `floorplan.ts` / `tool.tsx` / `move-tool.tsx` / `panel.tsx` / `parametrics.ts` / `preview.tsx`. Exports `builtinPlugin`. Depends on `editor`, `viewer`, and `core` via their public surfaces — the same surfaces a third-party plugin uses (peer-dep style). **Nothing in `core/`, `viewer/`, or `editor/` may import from `@pascal-app/nodes`.** The dependency arrow is one-way: framework code consults `nodeRegistry`, never reaches into a specific kind's folder.
### Triggers that mean "this is probably in the wrong package"
1. **Would the read-only `/viewer/[id]` route need this?** If no, it belongs in `apps/editor` / `packages/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.
6. **Does the new file mention a specific kind by name?** (`door-…`, `wall-…`, `item-…`, etc.) Then it belongs in `packages/nodes/src/<kind>/`, **not** under `packages/viewer/src/components/renderers/<kind>/`, `packages/viewer/src/systems/<kind>.ts`, `packages/editor/src/components/tools/<kind>/`, or `packages/editor/src/components/ui/panels/<kind>-panel.tsx`. Those legacy locations were deleted at Phase 6 cleanup — reintroducing one is a regression to the dispatch model.
7. **Does an `import` line read `from '@pascal-app/nodes'` inside `core/`, `viewer/`, or `editor/`?** Blocker. The Biome `noRestrictedImports` rule already bans this; if it slipped through, the framework is reaching down into the plugin.
Write the classification down before writing findings. If core gains "Floorplan" types, the viewer gains paint-mode vocabulary, a renderer grows editor awareness, or a kind-specific file appears outside `packages/nodes/src/<kind>/` — those are the blockers to lead with, not downstream symptoms.
## 4. Review checklist
### A. Package boundaries
- `packages/viewer/**` does not import from `@pascal-app/editor`, `apps/editor`, or `@pascal-app/nodes`, and does not reference `useEditor`, tool state, phase, or mode.
- `packages/core/**` does not import Three.js, react-three-fiber, `@pascal-app/viewer`, `@pascal-app/editor`, or `@pascal-app/nodes`.
- `packages/editor/**` does not import from `@pascal-app/nodes`.
- `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.
- No new `case '<kind>':` clauses (or equivalent kind-specific branching keyed on `node.type`) inside `packages/viewer/**` or `packages/editor/**`. Phase 6 deleted these; the dispatch happens via `nodeRegistry`. The exceptions left in tree are `treeNodeByType` (a lookup *map*, not a switch) and unit-formatting switches (`centimeters` / `feet` / `inches`). Any new `case 'door'|'wall'|'item'…` in a framework package is a blocker — the behavior belongs on the kind's `NodeDefinition`.
- 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. Node registry & composition (`packages/nodes`)
If the PR adds or modifies a node kind, check against `wiki/architecture/node-definitions.md` and `wiki/architecture/plugin-authoring.md`:
- **Three independent fields**: `def.geometry?: (node, ctx) => Object3D`, `def.renderer?: () => Promise<{ default }>`, `def.system?: () => Promise<{ default }>`. There is no discriminator — presence is participation. Setting all three is fine if the kind genuinely needs them; setting a `def.system` whose only job is to rebuild geometry on dirty is a smell — collapse to `def.geometry` and let `<GeometrySystem>` do the work.
- **Builders must be pure.** A `def.geometry` function must not import `useScene`, must not mutate the store, and must not depend on React context. Read other nodes via `GeometryContext` (`ctx.resolve` / `ctx.children` / `ctx.siblings` / `ctx.parent`).
- **Builders emit local-space children.** `<ParametricNodeRenderer>` binds `<group position={liveTransform?.position ?? node.position}>` in JSX. A builder that bakes world position into vertex coords, or a system that imperatively writes `group.position` / `group.rotation`, will desync R3F's prop binding — the node will snap to `(0,0,0)` after rebuild. Flag any imperative `group.position.set(...)` inside `def.geometry` or a registered system. (Tool-driven `sceneRegistry.nodes.get(id).position.set(...)` during a live drag is fine and is the documented pattern — see hook hygiene below.)
- **Tag geometry-built children.** `<GeometrySystem>` only disposes children carrying `userData.__fromGeometry = true`. Custom systems that imperatively add children to a registered group must follow the same convention if the group can host React-mounted children (e.g. shelf surfaces hosting items).
- **One registered mesh per node ID.** If a custom renderer mounts multiple objects, register the parent group (or whichever object the system needs to address via `sceneRegistry.nodes.get(id)`).
- **Previews must clone cached materials.** If `def.preview` calls the geometry builder and then sets `material.opacity = 0.5`, but the builder caches materials at module scope (most do, keyed on `material` / `materialPreset`), the mutation leaks into every committed instance. Clone, mutate the clone, reassign `mesh.material`, dispose only the clone on unmount. Reference: `nodes/src/shelf/preview.tsx`.
- **Schema changes must keep old scenes loadable.** Any diff that adds, renames, removes, or retypes a property on a node schema needs a load path for scenes saved before the change (parsed through `AnyNode` in `SceneState.setScene`). A *new* field needs a Zod `.default()` / `.optional()`. A *rename / removal / retype* needs a `migrateNodes` entry in `packages/core/src/store/use-scene.ts` that rewrites the legacy shape before parse — a `.default()` alone silently drops the old value. A schema diff that does neither is a blocker: it breaks every existing scene. See `wiki/architecture/node-schemas.md` § Schema Evolution.
- **Host kinds need `children` on the schema.** If `def.relations.hosts` is set, the schema must declare `children: z.array(z.string()).default([])` (and `migrateNodes` must patch existing scenes). Otherwise `useScene.createNode(child, parentId)` writes a `parent.children` entry into nothing and the host never sees the new child.
- **Movable opt-in.** `MoveTool` dispatches to `MoveRegistryNodeTool` only when `def.capabilities.movable` is set. Kinds with bespoke move semantics (wall endpoint drag with linked-wall cascade, slab vertex edit, etc.) deliberately omit `movable` and supply `def.affordanceTools.move` instead. Force-routing a bespoke-move kind through generic dispatch (`nodeRegistry.has(kind)` instead of `def.capabilities.movable`) is a regression — call it out. The bug history is documented in `plans/editor-node-registry.md` ("Capability-driven move dispatch").
- **Paint dispatch lives on `def.capabilities.paint`.** A paintable kind declares `resolveRole` / `buildPatch` / `applyPreview` (+ optional `getEffectiveMaterial`) on `PaintCapability`; the editor's selection-manager routes hover / click / preview through the generic dispatcher. A PR that adds an `if (node.type === '<kind>')` arm to paint-mode handling, paint-preview application, or material picker resolution is a regression — the behaviour belongs on the kind's `paint` capability. See `packages/core/src/registry/types.ts` (`PaintCapability`).
- **Slots + world-scale UVs for paintable surfaces.** A new kind (or geometry change) that exposes paintable parts must follow the unified slot convention, not reinvent it:
- **Paintable parts are slots, carried on the node.** Overrides live in a `slots` record (`slotId → MaterialRef`, `scene:`/`library:`) on the schema, resolved via `def.capabilities.paint` — not ad-hoc per-surface `material` / `materialPreset` fields, and not a parallel store. A new paintable kind whose schema lacks `slots` (or whose duplicate / preset / clone path drops it) is a blocker: it silently loses painted materials. (Slots are plain data — generic clone/parse preserves them; bespoke draft-rebuild placement paths must thread them through explicitly. Reference bug: item duplicate rebuilt the draft from `asset` and dropped `slots`.)
- **Texturable geometry emits UVs in metres (1 UV unit = 1 m).** Any `def.geometry` producing a surface a finish can tile onto must generate UVs at the same world scale walls / slabs / roofs use, because catalog finishes set `repeat` as tiles-per-metre. Unitless, bounding-box-normalised, or hardcoded UVs that don't scale with the surface are a blocker — finishes won't tile consistently. Flat-colour-only surfaces need no UVs. GLB item authoring follows the same contract via `slot_`-prefixed materials (case-insensitive, `slot_` → slot id). See `wiki/architecture/materials-and-themes.md` § "Texture world scale" and `wiki/architecture/item-authoring.md`.
- **Floor elevation lives on `def.capabilities.floorPlaced`.** Kinds that rest on a level and lift over overlapping slabs declare a `footprint` (and optional `applies` predicate) on `FloorPlacedConfig`; the generic `<FloorElevationSystem>` writes `slabElevation + node.position[1]` onto the registered mesh on each dirty mark. A new per-kind `useEffect` / per-kind system that recomputes Y from slab overlap is a regression — the per-kind block was lifted out of `ItemSystem` in Phase 6.1. See `packages/core/src/registry/types.ts` (`FloorPlacedConfig`).
- **Render-mode behaviour goes through `def.surfaceRole`.** Solid / Rendered / Clay viewer modes look up the kind's `surfaceRole` on `NodeDefinition` to pick the right material strategy (clay overrides, edge passes, theme overlays). New `if (node.type === '<kind>')` arms inside the render-mode pipeline or theme application are a regression — the behaviour belongs on `def.surfaceRole`. See `packages/core/src/registry/types.ts:529`.
- **Capability names must describe verbs, not host kinds.** `movable` / `paint` / `floorPlaced` / `cuttable` / `selectable` describe *what the node does*. A new capability named after a host kind (`slabAccessory`, `wallAccessory`, `siteAccessory`, anything `Xaccessory` / `Xhosted` shaped) couples the registry's type surface to one specific host and reads as precedent for the next reviewer. Blocker. Push back: generalise into a paired *host-side* capability ("I merge subtractive accessories from my children") + *accessory-side* capability ("I provide a cut geometry, cascade my dirty mark to my host's parent"). The single existing case — `capabilities.roofAccessory` (`packages/core/src/registry/types.ts:791`, consumed by `packages/viewer/src/systems/roof/roof-system.tsx`) — is documented tech debt; do not extend the pattern.
- **Per-kind files in legacy locations are a regression.** New `viewer/src/components/renderers/<kind>/*`, `viewer/src/systems/<kind>-system.tsx`, `editor/src/components/tools/<kind>/*`, `editor/src/components/ui/panels/<kind>-panel.tsx`, `editor/src/components/ui/helpers/<kind>-helper.tsx`, or inline `useMemo` floor-plan entry-builders inside `editor/src/components/editor/floorplan-panel.tsx` — all of these were systematically deleted at Phase 6. The behavior belongs on the kind's `NodeDefinition` (`def.renderer` / `def.system` / `def.geometry` / `def.tool` / `def.affordanceTools` / `parametrics.customPanel` / `def.toolHints` / `def.floorplan`).
- **Floor-plan output via `def.floorplan`.** New per-kind floor-plan rendering must return `FloorplanGeometry` from `def.floorplan(node, ctx)` and be rendered by `<FloorplanRegistryLayer>`. New inline branches in `floorplan-panel.tsx` are a blocker.
- **Plugin contract surface.** A PR that extends the v1 plugin surface — adding `plugin.materials`, `plugin.systems`, `plugin.panels`, or making plugins extend host stores (`useScene` / `useEditor` / `useViewer`) — is out of scope for the v1 contract documented in `wiki/architecture/plugin-authoring.md`. Either the change belongs as a new field on `NodeDefinition` (additive, doesn't bump `apiVersion`) or it needs its own plan.
### C. 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`.
- **Node code does not import `useScene` directly.** A kind's geometry / system / tool should read and write through `SceneApi` (passed in by the framework) or `GeometryContext`. Direct `useScene.getState()` calls inside `packages/nodes/src/<kind>/` are a smell — they bypass the registry's IoC point and make the code harder to test.
- **Live drag motion is imperative, not store-driven.** Tools must not call `useLiveTransforms.set(...)` per `grid:move` tick to animate registered parametric kinds — the selector path doesn't reliably re-render and the mesh visibly disappears mid-drag. Use `sceneRegistry.nodes.get(node.id)?.position.set(x, y, z)` instead, and commit once at the end via `useScene.temporal.getState().resume() → updateNode → pause()`. The reference implementation is `MoveRegistryNodeTool`. This is the *only* sanctioned use of imperative mesh transforms by a tool; flag any other location that does the same.
- **Data-driven drags preview via `useLiveNodeOverrides`, never per-tick `useScene`.** A kind whose geometry is recomputed from data fields (wall `start`/`end`, opening host-cut, endpoint reshape) previews by publishing field patches to `useLiveNodeOverrides` (merged by `getEffectiveWall` / `getEffectiveNode`), writing the scene store **once on commit**. A tool that calls `useScene.updateNodes`/`updateNode` on `grid:move` (or any per-pointer-move tick) is a **blocker** — it swaps the `nodes` map ref and re-renders every `useScene(s => s.nodes)` subscriber app-wide each frame (`markDirty` per tick is fine). Grep tell: `updateNode(s)?(` in an `onGridMove`/`onMove`/`applyPreview` path under `packages/nodes/src/<kind>/`. See `wiki/architecture/tools.md` § "Data-driven live drag".
### D. 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.
- Inside a `<XxxPanel>` (legacy or `parametrics.customPanel`-mounted), avoid `useScene(s => s.nodes[selectedId])` as a callback dep — it changes every tick and pushes `useCallback` into infinite-loop territory. The recipe is in `plans/editor-node-registry.md` under "Panel slider-drag fix recipe".
- **Per-node list renderers subscribe per-node, not to the whole live Map.** A list that draws one child per node (`FloorplanRegistryLayer` → `FloorplanRegistryEntry`) must have each child subscribe to its **own** slice (`useLiveTransforms(s => s.transforms.get(id))` / `overrides.get(id)`) and be `memo`'d with referentially stable props; the parent subscribes only to the stable id list. Subscribing the parent or a child to the whole `transforms`/`overrides` Map, dropping a `memo`, or passing unstable props re-renders all N children every drag tick — a flood that type-checks and passes tests. Sibling invalidation goes through a per-node epoch, not a whole-layer re-render. See `wiki/architecture/tools.md` § "Floorplan registry: per-node subscriptions".
### E. 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.
- **Editor overlay meshes must carry the editor layer.** Any new `<mesh>` / `<line*>` / `<points>` / `<sprite>` an editor overlay or tool component adds to the 3D scene (gizmos, handles, guides, previews, cursor meshes, marquees) must set `layers={EDITOR_LAYER}` — or `GRID_LAYER` for the ground grid, `ZONE_LAYER` for zone fills. The thumbnail/snapshot camera renders only layer 0, so an untagged overlay leaks into exports (and gets inked / SSGI-darkened in the live view). Flag any overlay-component primitive that omits the layer assignment. See `wiki/architecture/layers.md`.
- New node types are added by creating one folder under `packages/nodes/src/<kind>/` and registering its definition in `builtinPlugin.nodes`. Adding to a hand-maintained list elsewhere is a sign the registry hasn't absorbed that surface yet — check `plans/editor-node-registry.md` § "Known un-shimmed hardcoded lists" before assuming it's a violation.
- `AnyNode` is hand-maintained for now (full runtime derivation would lose static typing); `packages/nodes/src/index.test.ts` is the drift gate. If a PR adds a kind to `AnyNode` without adding it to `builtinPlugin.nodes` (or vice versa), the parity test catches it — but flag it in review too.
### F. Interaction scope, snapping & modifiers
Apply when the diff touches a tool, a `move-tool` / `selection` / endpoint / reshape file, `lib/interaction/**`, `lib/snapping-mode.ts`, or `use-interaction-scope`. Source of truth: `wiki/architecture/interaction-scope.md` and `wiki/architecture/tools.md`.
- **No new `useEditor` interaction flag.** "What the user is doing" is owned by `useInteractionScope` (`begin` / `update` / `end` / `endIf`). A new `useEditor` boolean for an in-flight interaction (`moving…`, `curving…`, `dragging…`, `editing…`, `…InFlight`) is a **blocker** — it goes through the scope. The legacy mirror flags are being retired, not extended.
- **Snapping is mode-driven; Shift is not a bypass.** A tool / `move-tool` / `selection` file that reads `event.shiftKey`, `event.nativeEvent?.shiftKey`, or `modifiers.shiftKey` to **bypass snapping** (raw cursor, skip grid, skip angle) is a **blocker** — the convention is Shift = *cycle the mode*, Alt = force/free. Snap state must come from `isGridSnapActive()` / `isMagneticSnapActive()` / `isAngleSnapActive()`. Grep tell: `shiftKey` near a snap / step / `projectToAngleLock` / alignment expression in `packages/nodes/src/<kind>/{tool,move-tool,selection}.tsx`. (Shift for *multi-select* in select mode, or a documented topology opt-out, is fine — confirm which it is.)
- **No hardcoded, ungated grid step.** A quantize that isn't gated on `isGridSnapActive()` — always `useEditor.getState().gridSnapStep`, or a constant `WALL_GRID_STEP` / `0.5` / `getSegmentGridStep()` applied unconditionally — ignores the active mode and is a **blocker**. The gated form is `const step = isGridSnapActive() ? useEditor.getState().gridSnapStep : 0`.
- **Snappable kinds declare `snapProfile`.** A kind whose tool snaps but whose `NodeDefinition` omits `snapProfile` (`'item' | 'structural'`) gets no contextual chip and the wrong default mode-set — flag it (suggestion, blocker if it ships a bespoke per-kind snapping switch instead).
- **Bespoke movers must not open a `moving` scope.** `useMovingNode()` reads the scope, and `tool-manager` mounts the generic `MoveRegistryNodeTool` whenever it's non-null. A bespoke `move-tool.tsx` that calls `begin(movingScope(...))` or `setMovingNode(node)` re-creates the dual-path double-handling (FPS collapse / teleport on move). **Blocker.** Mode-driven snapping inside a bespoke mover must resolve the mode without a global `moving` / `reshaping` scope (see `interaction-scope.md` § "Snapping mode & modifiers").
- **`event.altKey` is not an alignment bypass.** A drafting/preview path that reads `event.altKey` to suppress Figma-alignment is a **blocker** in any **new** or **touched** tool — alignment follows the magnetic snap mode (`bypass: !isMagneticSnapActive()`). Alt is force/free for placement/move; it is **not** a snap/alignment modifier. The one sanctioned Alt use outside force is the **wall/fence chain-mode toggle** (clean Alt-tap → `cycleWallChainMode` / `cycleFenceChainMode`, via `hooks/use-keyboard.ts` `isChainModeContext()`), allowed only because wall/fence drafting has no force role. Grep tell: `event.altKey` near an `align` / `bypass` expression in a `tool.tsx` / floorplan preview path.
- **Known-legacy exceptions (migrate on touch).** Tracked debt in `plans/editor-placement-interaction-overhaul.md`; a PR that **touches** one must migrate it, not extend it; a **new** tool on either legacy pattern is a blocker regardless. (1) `shiftKey` snap-bypass in the MEP move/endpoint tools (`packages/nodes/src/{duct-segment,pipe-segment,liquid-line,lineset,duct-fitting}/{move-tool,selection}.tsx`). (2) `altKey` alignment-bypass in the roof / polygon / slab pointer-move previews (`components/editor/floorplan-panel.tsx`) and the `resolveSlabPlanPointSnap` / `resolveCeilingPlanPointSnap` paths. **Already migrated — do not regress:** wall + fence drafting (both modifier patterns) and `zone` drafting (`components/tools/zone/zone-tool.tsx` — mode-driven grid/angle gates, no Shift bypass).
## 5. Output format
Group findings by severity:
- **Blocker** — violates a rule in `wiki/architecture/` or breaks a layer/package 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 wiki page (e.g. `wiki/architecture/viewer-isolation.md`, `wiki/architecture/node-definitions.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

View File

@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"Bash(npx turbo:*)"
]
}
}

View File

@ -0,0 +1 @@
../.agents/skills

View File

@ -0,0 +1 @@
../.agents/skills

View File

@ -0,0 +1 @@
../.agents/skills

View File

@ -0,0 +1,33 @@
# Optional environment variables — the editor works without any of these
# Google Maps API key (enables address search)
# NEXT_PUBLIC_GOOGLE_MAPS_API_KEY=your_google_maps_api_key
# Dev server port (default: 3000)
# PORT=3000
# AI design agent. The endpoint must implement the OpenAI-compatible chat API.
# PASCAL_AGENT_API_KEY=your_api_key
# PASCAL_AGENT_BASE_URL=https://api.openai.com/v1
# PASCAL_AGENT_MODEL=gpt-4.1-mini
# Optional agent safety limits.
# PASCAL_AGENT_MAX_STEPS=18
# PASCAL_AGENT_STEP_TIMEOUT_MS=90000
# PASCAL_AGENT_TOTAL_TIMEOUT_MS=300000
# Required in production. Configure the reverse proxy to overwrite this header
# before forwarding requests to Pascal; never expose the value to browser code.
# PASCAL_AGENT_PROXY_SECRET=replace_with_a_long_random_value
# Optional when Bun or the built MCP executable are not discoverable automatically.
# PASCAL_MCP_COMMAND=/absolute/path/to/bun
# PASCAL_MCP_ENTRY=/absolute/path/to/packages/mcp/dist/bin/pascal-mcp.js
# Shared scene storage used by the editor and each isolated Pascal MCP process.
# PASCAL_DATA_DIR=/absolute/path/to/pascal-data
# Hosted integration. The reverse proxy must authenticate the request, strip
# any client-supplied user header, and inject the trusted host user id.
# NEXT_PUBLIC_PASCAL_BASE_PATH=/pascal-service
# PASCAL_REQUIRE_HOST_USER=true

View File

@ -0,0 +1,64 @@
name: 🐛 Bug Report
description: Report something that isn't working correctly
labels: ["bug", "needs-triage"]
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to report a bug! Please fill out the sections below so we can reproduce and fix the issue.
- type: textarea
id: description
attributes:
label: What happened?
description: A clear description of the bug.
placeholder: Describe what went wrong...
validations:
required: true
- type: textarea
id: steps
attributes:
label: Steps to reproduce
description: How can we reproduce the issue?
placeholder: |
1. Go to '...'
2. Click on '...'
3. See error
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected behavior
description: What did you expect to happen?
validations:
required: true
- type: input
id: browser
attributes:
label: Browser & OS
description: e.g. Chrome 120, macOS 14
placeholder: Chrome 120, macOS 14
validations:
required: false
- type: textarea
id: screenshots
attributes:
label: Screenshots or screen recordings
description: |
A short screen recording is the fastest way to show us the bug.
Drag and drop a video file or paste a link — even a quick clip helps a lot.
validations:
required: false
- type: textarea
id: additional
attributes:
label: Additional context
description: Any other information that might be helpful (console errors, related issues, etc.)
validations:
required: false

View File

@ -0,0 +1,8 @@
blank_issues_enabled: false
contact_links:
- name: 💬 Questions & Help
url: https://github.com/pascalorg/editor/discussions/categories/q-a
about: Ask questions and get help from the community
- name: 💡 Ideas & Discussion
url: https://github.com/pascalorg/editor/discussions/categories/ideas
about: Share ideas and discuss features before opening a formal request

View File

@ -0,0 +1,41 @@
name: ✨ Feature Request
description: Suggest a new feature or improvement
labels: ["enhancement", "needs-triage"]
body:
- type: markdown
attributes:
value: |
We love hearing ideas from the community! Please describe what you'd like to see in Pascal Editor.
- type: textarea
id: problem
attributes:
label: Problem or motivation
description: What problem does this solve? Why do you want this?
placeholder: I'm always frustrated when...
validations:
required: true
- type: textarea
id: solution
attributes:
label: Proposed solution
description: How do you think this should work?
validations:
required: false
- type: textarea
id: alternatives
attributes:
label: Alternatives considered
description: Any workarounds or alternative approaches you've thought about?
validations:
required: false
- type: textarea
id: additional
attributes:
label: Additional context
description: Mockups, screenshots, references to other tools, etc.
validations:
required: false

View File

@ -0,0 +1 @@
../AGENTS.md

View File

@ -0,0 +1,27 @@
## What does this PR do?
<!-- A brief description of the change. Link to a related issue if applicable: Fixes #123 -->
## How to test
<!-- Steps for reviewers to verify this change works -->
1.
2.
3.
## Screenshots / screen recording
<!--
🎥 Screen recordings are strongly encouraged for any visual or interactive change.
Even a quick 15-second clip helps reviewers understand the change far better than screenshots alone.
Drag and drop a video or paste a link here.
-->
## Checklist
- [ ] I've tested this locally with `bun dev`
- [ ] My code follows the existing code style (run `bun check` to verify)
- [ ] I've updated relevant documentation (if applicable)
- [ ] This PR targets the `main` branch

View File

@ -0,0 +1,28 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
quality:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Lint & format check
run: bun run check
- name: Type check
run: bun run check-types

View File

@ -0,0 +1,52 @@
name: mcp-ci
on:
push:
branches: [main]
paths:
- 'packages/mcp/**'
- 'packages/core/**'
- 'apps/editor/app/api/scenes/**'
- 'apps/editor/lib/scene-*'
- 'apps/editor/package.json'
- 'bun.lock'
- '.github/workflows/mcp-ci.yml'
pull_request:
paths:
- 'packages/mcp/**'
- 'packages/core/**'
- 'apps/editor/app/api/scenes/**'
- 'apps/editor/lib/scene-*'
- 'apps/editor/package.json'
- 'bun.lock'
- '.github/workflows/mcp-ci.yml'
jobs:
ci:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.0
- name: Install
run: bun install --frozen-lockfile
- name: Build core
run: bun run --cwd packages/core build
- name: Build mcp
run: bun run --cwd packages/mcp build
- name: Test mcp
run: bun test --cwd packages/mcp
- name: Test editor scene API
run: bun test apps/editor/lib/scene-store-server.test.ts apps/editor/lib/scene-api-security.test.ts
- name: Biome check
run: bunx biome check packages/mcp apps/editor/lib/scene-store-server.ts apps/editor/lib/scene-api-security.ts apps/editor/app/api/scenes

View File

@ -0,0 +1,221 @@
name: Release
on:
workflow_dispatch:
inputs:
package:
description: "Package to release"
required: true
type: choice
options:
- core
- viewer
- editor
- mcp
- ifc-converter
- all
bump:
description: "Version bump (use 'none' to publish current version as-is)"
required: true
type: choice
options:
- patch
- minor
- major
- none
dry-run:
description: "Dry run (no publish)"
required: false
type: boolean
default: false
jobs:
release:
runs-on: ubuntu-latest
environment: npm
permissions:
contents: write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: oven-sh/setup-bun@v2
- uses: actions/setup-node@v4
with:
node-version: 22
registry-url: "https://registry.npmjs.org"
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Configure git
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
- 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
if [ "$BUMP" = "none" ]; then echo "$v"; return; 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 ifc-converter; 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:]' | tr '-' '_')
# 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 ifc-converter; do
FILE=packages/$pkg/package.json
for dep in core viewer editor mcp ifc-converter; 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 ifc-converter; 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: |
bun run build
if [ "${{ inputs.dry-run }}" = "true" ]; then
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@$CORE_VERSION"
fi
- name: Build & publish viewer
if: inputs.package == 'viewer' || inputs.package == 'all'
working-directory: packages/viewer
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
bun run build
if [ "${{ inputs.dry-run }}" = "true" ]; then
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@$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: Build & publish ifc-converter
if: inputs.package == 'ifc-converter' || inputs.package == 'all'
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
# ifc-converter depends on @pascal-app/core (workspace) — build it first
bun run build --filter @pascal-app/core 2>/dev/null || (cd packages/core && bun run build)
cd packages/ifc-converter
bun run build
if [ "${{ inputs.dry-run }}" = "true" ]; then
echo "🏜️ Dry run — would publish @pascal-app/ifc-converter@$IFC_CONVERTER_VERSION"
npm publish --dry-run --access public
else
npm publish --access public
echo "📦 Published @pascal-app/ifc-converter@$IFC_CONVERTER_VERSION"
fi
- name: Commit version bumps & tag
if: inputs.dry-run == false
run: |
git add -A
PKGS=""
TAGS=""
if [ -n "$CORE_VERSION" ]; then
PKGS="$PKGS @pascal-app/core@$CORE_VERSION"
TAGS="$TAGS @pascal-app/core@$CORE_VERSION"
fi
if [ -n "$VIEWER_VERSION" ]; then
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
if [ -n "$IFC_CONVERTER_VERSION" ]; then
PKGS="$PKGS @pascal-app/ifc-converter@$IFC_CONVERTER_VERSION"
TAGS="$TAGS @pascal-app/ifc-converter@$IFC_CONVERTER_VERSION"
fi
git commit -m "release:${PKGS}"
for TAG in $TAGS; do
git tag "$TAG"
done
git push --follow-tags

56
services/pascal-editor/.gitignore vendored Normal file
View File

@ -0,0 +1,56 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# Dependencies
node_modules
package-lock.json
.pnp
.pnp.js
# Local env files
.env
.env.local
.env.development.local
.env.test.local
.env.production
.env.production.local
# Testing
coverage
# Turbo
.turbo
# Supabase local state
supabase/.branches/
supabase/.temp/
# Vercel
.vercel
# Build Outputs
.next/
out/
build
dist
*.tsbuildinfo
# Debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Misc
.DS_Store
*.pem
/.playwright-mcp
og-test
.env*.local
# Worktrees
.worktrees
# Local IDE/agent configs
.claude/launch.json
.claude/settings.local.json
.vscode/launch.json

View File

View File

@ -0,0 +1,41 @@
{
"typescript.tsdk": "node_modules/typescript/lib",
"biome.configPath": "./biome.jsonc",
"editor.defaultFormatter": "biomejs.biome",
"editor.formatOnSave": false,
"editor.formatOnPaste": true,
"emmet.showExpandedAbbreviation": "never",
"editor.codeActionsOnSave": {
"source.fixAll.biome": "always",
"quickfix.biome": "always",
"source.organizeImports.biome": "always",
"source.organizeImports": "never"
},
"[typescript]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[json]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[javascript]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[jsonc]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[typescriptreact]": {
"editor.defaultFormatter": "biomejs.biome",
"editor.formatOnPaste": false
},
"[javascriptreact]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[css]": {
"editor.defaultFormatter": "biomejs.biome"
},
"css.lint.unknownAtRules": "ignore",
"[graphql]": {
"editor.defaultFormatter": "biomejs.biome"
},
"evenBetterToml.formatter.allowedBlankLines": 4
}

View File

@ -0,0 +1,52 @@
# Agent Instructions — `pascalorg/editor`
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.
## Repo Shape
| 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 |
## Where to look
- **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`.
`CLAUDE.md`, `GEMINI.md`, and `.github/copilot-instructions.md` are symlinks to this file. Codex reads this file directly.
## Layer Boundaries (read once, internalise)
- **`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.
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`.
## When making architecture-sensitive changes
Read the relevant page in `wiki/architecture/` **before** writing code. The page list lives in `wiki/architecture/README.md`. As a minimum:
- Adding a node type → `node-schemas.md`, `renderers.md`, `systems.md`
- Adding a tool → `tools.md`, `spatial-queries.md`, `events.md`
- Adding / changing a placement or move interaction → `tools.md` ("2D ↔ 3D behavioral parity": applicable behaviors must exist in both views; port the change to the sibling 2D/3D file in the same PR)
- 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`
## When reviewing a PR
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.
## Operating rules
- 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*.

View File

@ -0,0 +1,59 @@
# Changelog
## 0.6.0 (2026-04-21)
### Features
- **Multi-surface material system** — per-surface materials for walls, stairs, roofs with click-targeted 3D editing ([#266](https://github.com/pascalorg/editor/pull/266)) by [@sudhir9297](https://github.com/sudhir9297)
- **Automatic wall-room generation** — closed wall loops auto-split and generate slabs ([#255](https://github.com/pascalorg/editor/pull/255), [#257](https://github.com/pascalorg/editor/pull/257)) by [@sudhir9297](https://github.com/sudhir9297)
- **Stair-slab integration** — stair-driven cutouts in slabs and ceilings, auto ceilings from wall loops
- **Curved fence support** + endpoint move tools ([#267](https://github.com/pascalorg/editor/pull/267)) by [@sudhir9297](https://github.com/sudhir9297)
- **13 material presets** — granite, marble, parquet, wallpaper, wood and more ([#231](https://github.com/pascalorg/editor/pull/231)) by [@sudhir9297](https://github.com/sudhir9297)
- **Export scene system** — GLB, STL, OBJ formats ([#203](https://github.com/pascalorg/editor/pull/203)) by [@zephran-dev](https://github.com/zephran-dev), with STL/OBJ groundwork by [@mvanhorn](https://github.com/mvanhorn) ([#175](https://github.com/pascalorg/editor/pull/175))
- **Street view / walkthrough mode** ([#173](https://github.com/pascalorg/editor/pull/173)) by [@Yashism](https://github.com/Yashism)
- **Duplicate project** ([#178](https://github.com/pascalorg/editor/pull/178)) by [@kleenkanteen](https://github.com/kleenkanteen)
- **Editable wall length slider** ([#195](https://github.com/pascalorg/editor/pull/195)) by [@zephran-dev](https://github.com/zephran-dev)
- **Infinity dragging slider** using PointerLock API ([#206](https://github.com/pascalorg/editor/pull/206)) by [@claygeo](https://github.com/claygeo)
- **Material system enhancements** ([#201](https://github.com/pascalorg/editor/pull/201)) by [@PMAT77](https://github.com/PMAT77)
- **Editor layout redesign v2** + 3D box select
- **Move/rotate building** + relative positioning for all tools
- **Grid snap toolbar controls**
- **Cut-out button** in floating action menu for slabs and ceilings
### Fixes
- **WebGPU renderer** — await `renderer.init()` in Canvas GL factory ([#233](https://github.com/pascalorg/editor/pull/233)) by [@b9llach](https://github.com/b9llach)
- **WebGPU fallback** — skip post-processing when unavailable ([#234](https://github.com/pascalorg/editor/pull/234)) by [@b9llach](https://github.com/b9llach)
- **Crash on mode switch** — fix crash when switching to Furniture mode ([#237](https://github.com/pascalorg/editor/pull/237)) by [@txhno](https://github.com/txhno)
- **Crash on duplicate** — prevent crash when duplicating elements ([#239](https://github.com/pascalorg/editor/pull/239)) by [@nnhhoang](https://github.com/nnhhoang)
- **Delete walls/slabs** via floating action menu ([#180](https://github.com/pascalorg/editor/pull/180)) by [@nnhhoang](https://github.com/nnhhoang)
- **Counter-clockwise rotation** — T key for CCW rotation on selected nodes ([#184](https://github.com/pascalorg/editor/pull/184)) by [@nnhhoang](https://github.com/nnhhoang)
- **Scene singleton cleanup** — release singletons on Editor unmount ([#214](https://github.com/pascalorg/editor/pull/214)) by [@geopenta](https://github.com/geopenta)
- **State management & memory leaks** ([#152](https://github.com/pascalorg/editor/pull/152)) by [@hobostay](https://github.com/hobostay)
- **Ghost wall prevention** — use WALL_MIN_LENGTH constant ([#168](https://github.com/pascalorg/editor/pull/168)) by [@zephran-dev](https://github.com/zephran-dev)
- **Catalog image optimization** — add sizes and loading props ([#189](https://github.com/pascalorg/editor/pull/189)) by [@korvixhq](https://github.com/korvixhq)
- **Code cleanup** — remove unused `@ts-expect-error` directive ([#150](https://github.com/pascalorg/editor/pull/150)) by [@cs68614-hash](https://github.com/cs68614-hash)
- Robust undo/redo with nested history pause/resume
- Post-processing recovery after duplicate scene mutations
- Improved snapping across all geometry types
- Thumbnails, placement, and responsiveness improvements
- Stair elevation sync with floor slabs
### Contributors
A huge thank you to everyone who contributed to this release! 🎉
- [@sudhir9297](https://github.com/sudhir9297) — material system, wall-room generation, curved walls, stairs, fences (7 PRs!)
- [@zephran-dev](https://github.com/zephran-dev) — export system, wall length slider, ghost wall fix
- [@nnhhoang](https://github.com/nnhhoang) — rotation controls, delete actions, crash fix
- [@b9llach](https://github.com/b9llach) — WebGPU renderer fixes
- [@txhno](https://github.com/txhno) — furniture mode crash fix
- [@Yashism](https://github.com/Yashism) — street view / walkthrough mode
- [@claygeo](https://github.com/claygeo) — infinity dragging slider
- [@geopenta](https://github.com/geopenta) — scene singleton cleanup
- [@kleenkanteen](https://github.com/kleenkanteen) — duplicate project feature
- [@mvanhorn](https://github.com/mvanhorn) — STL/OBJ export formats
- [@PMAT77](https://github.com/PMAT77) — material system enhancements
- [@korvixhq](https://github.com/korvixhq) — catalog image optimization
- [@hobostay](https://github.com/hobostay) — state management & memory leak fixes
- [@cs68614-hash](https://github.com/cs68614-hash) — code cleanup

View File

@ -0,0 +1 @@
AGENTS.md

View File

@ -0,0 +1,75 @@
# Contributing to Pascal Editor
Thanks for your interest in contributing! We welcome all kinds of contributions — bug fixes, new features, documentation, and ideas.
## Getting started
### Prerequisites
- [Bun](https://bun.sh/) 1.3+ (or Node.js 18+)
### Setup
```bash
git clone https://github.com/pascalorg/editor.git
cd editor
bun install
bun dev
```
The editor will be running at **http://localhost:3000**. That's it!
### Optional
Copy `.env.example` to `.env` and add a Google Maps API key if you want address search functionality. The editor works fully without it.
## Making changes
### Code style
We use [Biome](https://biomejs.dev/) for linting and formatting. Before submitting a PR:
```bash
bun check # Check for issues
bun check:fix # Auto-fix issues
```
### Project structure
| Package | What it does |
|---------|-------------|
| `packages/core` | Scene schema, state management, systems — no UI |
| `packages/viewer` | 3D rendering with React Three Fiber |
| `apps/editor` | The full editor app (Next.js) |
A key rule: **`packages/viewer` must never import from `apps/editor`**. The viewer is a standalone component; editor-specific behavior is injected via props/children.
### Building a plugin
New node kinds and sidebar panels can ship as a plugin instead of editing the built-ins. Read [`wiki/architecture/plugin-authoring.md`](wiki/architecture/plugin-authoring.md) for the contract, and copy [`packages/plugin-trees`](packages/plugin-trees) as a worked example.
## Submitting a PR
1. **Fork the repo** and create a branch from `main`
2. **Make your changes** and test locally with `bun dev`
3. **Run `bun check`** to make sure linting passes
4. **Open a PR** with a clear description of what changed and why
5. **Link related issues** if applicable (e.g., "Fixes #42")
### PR tips
- Keep PRs focused — one feature or fix per PR
- Include screenshots or recordings for visual changes
- If you're unsure about an approach, open an issue or discussion first
## Reporting bugs
Use the [Bug Report](https://github.com/pascalorg/editor/issues/new?template=bug_report.yml) template. Include steps to reproduce — this helps us fix things faster.
## Suggesting features
Use the [Feature Request](https://github.com/pascalorg/editor/issues/new?template=feature_request.yml) template, or start a [Discussion](https://github.com/pascalorg/editor/discussions) if you want to brainstorm first.
## Questions?
Head to [Discussions](https://github.com/pascalorg/editor/discussions) — we're happy to help!

View File

@ -0,0 +1 @@
AGENTS.md

View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Pascal Group Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -0,0 +1,429 @@
# Pascal Editor
A 3D building editor built with React Three Fiber and WebGPU.
[![MIT License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
[![npm @pascal-app/core](https://img.shields.io/npm/v/@pascal-app/core?label=%40pascal-app%2Fcore)](https://www.npmjs.com/package/@pascal-app/core)
[![npm @pascal-app/viewer](https://img.shields.io/npm/v/@pascal-app/viewer?label=%40pascal-app%2Fviewer)](https://www.npmjs.com/package/@pascal-app/viewer)
[![Discord](https://img.shields.io/badge/Discord-Join%20Server-5865F2?logo=discord&logoColor=white)](https://discord.gg/SaBRA9t2)
[![X (Twitter)](https://img.shields.io/badge/follow-%40pascal__app-black?logo=x&logoColor=white)](https://x.com/pascal_app)
https://github.com/user-attachments/assets/8b50e7cf-cebe-4579-9cf3-8786b35f7b6b
## Repository Architecture
This is a Turborepo monorepo with three main packages:
```
editor/
├── apps/
│ └── editor/ # Next.js application
├── packages/
│ ├── core/ # Schema definitions, state management, systems
│ ├── viewer/ # 3D rendering components
│ └── ui/ # Shared UI components
```
### Separation of Concerns
| Package | Responsibility |
|---------|---------------|
| **@pascal-app/core** | Node schemas, scene state (Zustand), systems (geometry generation), spatial queries, event bus |
| **@pascal-app/viewer** | 3D rendering via React Three Fiber, default camera/controls, post-processing |
| **apps/editor** | UI components, tools, custom behaviors, editor-specific systems |
The **viewer** renders the scene with sensible defaults. The **editor** extends it with interactive tools, selection management, and editing capabilities.
### Stores
Each package has its own Zustand store for managing state:
| Store | Package | Responsibility |
|-------|---------|----------------|
| `useScene` | `@pascal-app/core` | Scene data: nodes, root IDs, dirty nodes, CRUD operations. Persisted to IndexedDB with undo/redo via Zundo. |
| `useViewer` | `@pascal-app/viewer` | Viewer state: current selection (building/level/zone IDs), level display mode (stacked/exploded/solo), camera mode. |
| `useEditor` | `apps/editor` | Editor state: active tool, structure layer visibility, panel states, editor-specific preferences. |
**Access patterns:**
```typescript
// Subscribe to state changes (React component)
const nodes = useScene((state) => state.nodes)
const levelId = useViewer((state) => state.selection.levelId)
const activeTool = useEditor((state) => state.tool)
// Access state outside React (callbacks, systems)
const node = useScene.getState().nodes[id]
useViewer.getState().setSelection({ levelId: 'level_123' })
```
---
## Core Concepts
### Nodes
Nodes are the data primitives that describe the 3D scene. All nodes extend `BaseNode`:
```typescript
BaseNode {
id: string // Auto-generated with type prefix (e.g., "wall_abc123")
type: string // Discriminator for type-safe handling
parentId: string | null // Parent node reference
visible: boolean
camera?: Camera // Optional saved camera position
metadata?: JSON // Arbitrary metadata (e.g., { isTransient: true })
}
```
**Node Hierarchy:**
```
Site
└── Building
└── Level
├── Wall → Item (doors, windows)
├── Slab
├── Ceiling → Item (lights)
├── Roof
├── Zone
├── Scan (3D reference)
└── Guide (2D reference)
```
Nodes are stored in a **flat dictionary** (`Record<id, Node>`), not a nested tree. Parent-child relationships are defined via `parentId` and `children` arrays.
---
### Scene State (Zustand Store)
The scene is managed by a Zustand store in `@pascal-app/core`:
```typescript
useScene.getState() = {
nodes: Record<id, AnyNode>, // All nodes
rootNodeIds: string[], // Top-level nodes (sites)
dirtyNodes: Set<string>, // Nodes pending system updates
createNode(node, parentId),
updateNode(id, updates),
deleteNode(id),
}
```
**Middleware:**
- **Persist** - Saves to IndexedDB (excludes transient nodes)
- **Temporal** (Zundo) - Undo/redo with 50-step history
---
### Scene Registry
The registry maps node IDs to their Three.js objects for fast lookup:
```typescript
sceneRegistry = {
nodes: Map<id, Object3D>, // ID → 3D object
byType: {
wall: Set<id>,
item: Set<id>,
zone: Set<id>,
// ...
}
}
```
Renderers register their refs using the `useRegistry` hook:
```tsx
const ref = useRef<Mesh>(null!)
useRegistry(node.id, 'wall', ref)
```
This allows systems to access 3D objects directly without traversing the scene graph.
---
### Node Renderers
Renderers are React components that create Three.js objects for each node type:
```
SceneRenderer
└── NodeRenderer (dispatches by type)
├── BuildingRenderer
├── LevelRenderer
├── WallRenderer
├── SlabRenderer
├── ZoneRenderer
├── ItemRenderer
└── ...
```
**Pattern:**
1. Renderer creates a placeholder mesh/group
2. Registers it with `useRegistry`
3. Systems update geometry based on node data
Example (simplified):
```tsx
const WallRenderer = ({ node }) => {
const ref = useRef<Mesh>(null!)
useRegistry(node.id, 'wall', ref)
return (
<mesh ref={ref}>
<boxGeometry args={[0, 0, 0]} /> {/* Replaced by WallSystem */}
<meshStandardMaterial />
{node.children.map(id => <NodeRenderer key={id} nodeId={id} />)}
</mesh>
)
}
```
---
### Systems
Systems are React components that run in the render loop (`useFrame`) to update geometry and transforms. They process **dirty nodes** marked by the store.
**Core Systems (in `@pascal-app/core`):**
| System | Responsibility |
|--------|---------------|
| `WallSystem` | Generates wall geometry with mitering and CSG cutouts for doors/windows |
| `SlabSystem` | Generates floor geometry from polygons |
| `CeilingSystem` | Generates ceiling geometry |
| `RoofSystem` | Generates roof geometry |
| `ItemSystem` | Positions items on walls, ceilings, or floors (slab elevation) |
**Viewer Systems (in `@pascal-app/viewer`):**
| System | Responsibility |
|--------|---------------|
| `LevelSystem` | Handles level visibility and vertical positioning (stacked/exploded/solo modes) |
| `ScanSystem` | Controls 3D scan visibility |
| `GuideSystem` | Controls guide image visibility |
**Processing Pattern:**
```typescript
useFrame(() => {
for (const id of dirtyNodes) {
const obj = sceneRegistry.nodes.get(id)
const node = useScene.getState().nodes[id]
// Update geometry, transforms, etc.
updateGeometry(obj, node)
dirtyNodes.delete(id)
}
})
```
---
### Dirty Nodes
When a node changes, it's marked as **dirty** in `useScene.getState().dirtyNodes`. Systems check this set each frame and only recompute geometry for dirty nodes.
```typescript
// Automatic: createNode, updateNode, deleteNode mark nodes dirty
useScene.getState().updateNode(wallId, { thickness: 0.2 })
// → wallId added to dirtyNodes
// → WallSystem regenerates geometry next frame
// → wallId removed from dirtyNodes
```
**Manual marking:**
```typescript
useScene.getState().dirtyNodes.add(wallId)
```
---
### Event Bus
Inter-component communication uses a typed event emitter (mitt):
```typescript
// Node events
emitter.on('wall:click', (event) => { ... })
emitter.on('item:enter', (event) => { ... })
emitter.on('zone:context-menu', (event) => { ... })
// Grid events (background)
emitter.on('grid:click', (event) => { ... })
// Event payload
NodeEvent {
node: AnyNode
position: [x, y, z]
localPosition: [x, y, z]
normal?: [x, y, z]
stopPropagation: () => void
}
```
---
### Spatial Grid Manager
Handles collision detection and placement validation:
```typescript
spatialGridManager.canPlaceOnFloor(levelId, position, dimensions, rotation)
spatialGridManager.canPlaceOnWall(wallId, t, height, dimensions)
spatialGridManager.getSlabElevationAt(levelId, x, z)
```
Used by item placement tools to validate positions and calculate slab elevations.
---
## Editor Architecture
The editor extends the viewer with:
### Tools
Tools are activated via the toolbar and handle user input for specific operations:
- **SelectTool** - Selection and manipulation
- **WallTool** - Draw walls
- **ZoneTool** - Create zones
- **ItemTool** - Place furniture/fixtures
- **SlabTool** - Create floor slabs
### Selection Manager
The editor uses a custom selection manager with hierarchical navigation:
```
Site → Building → Level → Zone → Items
```
Each depth level has its own selection strategy for hover/click behavior.
### Editor-Specific Systems
- `ZoneSystem` - Controls zone visibility based on level mode
- Custom camera controls with node focusing
---
## Data Flow
```
User Action (click, drag)
↓
Tool Handler
↓
useScene.createNode() / updateNode()
↓
Node added/updated in store
Node marked dirty
↓
React re-renders NodeRenderer
useRegistry() registers 3D object
↓
System detects dirty node (useFrame)
Updates geometry via sceneRegistry
Clears dirty flag
```
---
## Building a Plugin
The editor is extensible: a plugin ships node kinds (schema, 3D/2D rendering, placement tools, inspector parametrics) and left-rail panels through the same `Plugin` manifest the built-ins use — there is no separate internal API.
- **Contract reference** — [`wiki/architecture/plugin-authoring.md`](wiki/architecture/plugin-authoring.md): the `Plugin` shape, panel contributions, discovery (`setPluginDiscovery`), lifecycle, and what's in/out of v1.
- **Worked example** — [`packages/plugin-trees`](packages/plugin-trees): a first-party plugin (procedural trees, flowers, grass + a presets panel) structurally identical to a third-party pack. Copy it as a starting point.
---
## Technology Stack
- **React 19** + **Next.js 16**
- **Three.js** (WebGPU renderer)
- **React Three Fiber** + **Drei**
- **Zustand** (state management)
- **Zod** (schema validation)
- **Zundo** (undo/redo)
- **three-bvh-csg** (Boolean geometry operations)
- **Turborepo** (monorepo management)
- **Bun** (package manager)
---
## Getting Started
### Development
Run the development server from the **root directory** to enable hot reload for all packages:
```bash
# Install dependencies
bun install
# Run development server (builds packages + starts editor with watch mode)
bun dev
# This will:
# 1. Build @pascal-app/core and @pascal-app/viewer
# 2. Start watching both packages for changes
# 3. Start the Next.js editor dev server
# Open http://localhost:3000
```
**Important:** Always run `bun dev` from the root directory to ensure the package watchers are running. This enables hot reload when you edit files in `packages/core/src/` or `packages/viewer/src/`.
### Building for Production
```bash
# Build all packages
turbo build
# Build specific package
turbo build --filter=@pascal-app/core
```
### Publishing Packages
```bash
# Build packages
turbo build --filter=@pascal-app/core --filter=@pascal-app/viewer
# Publish to npm
npm publish --workspace=@pascal-app/core --access public
npm publish --workspace=@pascal-app/viewer --access public
```
---
## Key Files
| Path | Description |
|------|-------------|
| `packages/core/src/schema/` | Node type definitions (Zod schemas) |
| `packages/core/src/store/use-scene.ts` | Scene state store |
| `packages/core/src/hooks/scene-registry/` | 3D object registry |
| `packages/core/src/systems/` | Geometry generation systems |
| `packages/viewer/src/components/renderers/` | Node renderers |
| `packages/viewer/src/components/viewer/` | Main Viewer component |
| `apps/editor/components/tools/` | Editor tools |
| `apps/editor/store/` | Editor-specific state |
---
## Contributors
<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>
---
<a href="https://trendshift.io/repositories/23831" target="_blank"><img src="https://trendshift.io/api/badge/repositories/23831" alt="pascalorg/editor | Trendshift" width="250" height="55"/></a>

View File

@ -0,0 +1,97 @@
# Pascal Editor — Setup
## Prerequisites
- [Bun](https://bun.sh/) 1.3+ (recommended), or Node.js 22+
## Quick Start
```bash
bun install
bun dev
```
The editor will be running at **http://localhost:3000**.
## Environment Variables (optional)
Copy `.env.example` to `.env` if you need:
```bash
cp .env.example .env
```
| Variable | Required | Description |
|----------|----------|-------------|
| `NEXT_PUBLIC_GOOGLE_MAPS_API_KEY` | No | Enables address search in the editor |
| `PORT` | No | Dev server port (default: 3000) |
## AI Design Agent
Saved scenes expose an `AI Designer` sidebar. Configure an OpenAI-compatible
model endpoint before using it:
```env
PASCAL_AGENT_API_KEY=your_api_key
PASCAL_AGENT_BASE_URL=https://api.openai.com/v1
PASCAL_AGENT_MODEL=gpt-4.1-mini
PASCAL_DATA_DIR=/absolute/path/to/pascal-data
PASCAL_AGENT_PROXY_SECRET=replace_with_a_long_random_value
```
The editor and Agent MCP processes must use the same `PASCAL_DATA_DIR` or
`PASCAL_DB_PATH`. Each chat request launches an isolated `pascal-mcp` stdio
process, binds it to the current scene, and closes it when streaming finishes.
This prevents concurrent users from sharing the MCP bridge's in-memory scene.
The default safety limits are 18 tool steps, 90 seconds per tool, and 5 minutes
per turn. Override them with `PASCAL_AGENT_MAX_STEPS`,
`PASCAL_AGENT_STEP_TIMEOUT_MS`, and `PASCAL_AGENT_TOTAL_TIMEOUT_MS`.
When the application runs under Node rather than Bun, make Bun available on
`PATH` or set `PASCAL_MCP_COMMAND` to its absolute path. Set
`PASCAL_MCP_ENTRY` when the built MCP executable is outside the monorepo.
Production requests must pass through a trusted reverse proxy that authenticates
the user, strips any incoming `X-Pascal-Agent-Proxy-Secret` header, and injects
the configured value before forwarding to Pascal. Bind the Pascal service to a
private interface so users cannot bypass the proxy.
For a same-domain hosted deployment, set
`NEXT_PUBLIC_PASCAL_BASE_PATH=/pascal-service` before building and
`PASCAL_REQUIRE_HOST_USER=true` at build and runtime. The reverse proxy must
also strip and inject `X-Pascal-User-Id`. New scenes persist that value as
`ownerId`; list, read, mutation, event-stream, and Agent routes reject scenes
owned by another user.
The standalone app scripts force Next.js to run under Bun because scene
persistence uses built-in SQLite. Starting the app with Node 20 or earlier will
fail when a scene is first loaded or saved.
The core editor works without environment variables; the AI agent remains disabled until configured.
## Monorepo Structure
```
├── apps/
│ └── editor/ # Next.js editor application
├── packages/
│ ├── core/ # @pascal-app/core — Scene schema, state, systems
│ ├── viewer/ # @pascal-app/viewer — 3D rendering
│ └── ui/ # Shared UI components
└── tooling/ # Build & release tooling
```
## Scripts
| Command | Description |
|---------|-------------|
| `bun dev` | Start the development server |
| `bun build` | Build all packages |
| `bun check` | Lint and format check (Biome) |
| `bun check:fix` | Auto-fix lint and format issues |
| `bun check-types` | TypeScript type checking |
## Contributing
See [CONTRIBUTING.md](./CONTRIBUTING.md) for guidelines on submitting PRs and reporting issues.

View File

@ -0,0 +1,37 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
.yarn/install-state.gz
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# env files (can opt-in for commiting if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
.env*.local

View File

@ -0,0 +1,364 @@
# Pascal Editor
A 3D building editor built with React Three Fiber and WebGPU.
## Repository Architecture
This is a Turborepo monorepo with three main packages:
```
editor/
├── apps/
│ └── editor/ # Next.js application (this package)
├── packages/
│ ├── core/ # Schema definitions, state management, systems
│ └── viewer/ # 3D rendering components
```
### Separation of Concerns
| Package | Responsibility |
|---------|---------------|
| **@pascal-app/core** | Node schemas, scene state (Zustand), systems (geometry generation), spatial queries, event bus |
| **@pascal-app/viewer** | 3D rendering via React Three Fiber, default camera/controls, post-processing |
| **apps/editor** | UI components, tools, custom behaviors, editor-specific systems |
The **viewer** renders the scene with sensible defaults. The **editor** extends it with interactive tools, selection management, and editing capabilities.
### Stores
Each package has its own Zustand store for managing state:
| Store | Package | Responsibility |
|-------|---------|----------------|
| `useScene` | `@pascal-app/core` | Scene data: nodes, root IDs, dirty nodes, CRUD operations. Persisted to IndexedDB with undo/redo via Zundo. |
| `useViewer` | `@pascal-app/viewer` | Viewer state: current selection (building/level/zone IDs), level display mode (stacked/exploded/solo), camera mode. |
| `useEditor` | `apps/editor` | Editor state: active tool, structure layer visibility, panel states, editor-specific preferences. |
**Access patterns:**
```typescript
// Subscribe to state changes (React component)
const nodes = useScene((state) => state.nodes)
const levelId = useViewer((state) => state.selection.levelId)
const activeTool = useEditor((state) => state.tool)
// Access state outside React (callbacks, systems)
const node = useScene.getState().nodes[id]
useViewer.getState().setSelection({ levelId: 'level_123' })
```
---
## Core Concepts
### Nodes
Nodes are the data primitives that describe the 3D scene. All nodes extend `BaseNode`:
```typescript
BaseNode {
id: string // Auto-generated with type prefix (e.g., "wall_abc123")
type: string // Discriminator for type-safe handling
parentId: string | null // Parent node reference
visible: boolean
camera?: Camera // Optional saved camera position
metadata?: JSON // Arbitrary metadata (e.g., { isTransient: true })
}
```
**Node Hierarchy:**
```
Site
└── Building
└── Level
├── Wall → Item (doors, windows)
├── Slab
├── Ceiling → Item (lights)
├── Roof
├── Zone
├── Scan (3D reference)
└── Guide (2D reference)
```
Nodes are stored in a **flat dictionary** (`Record<id, Node>`), not a nested tree. Parent-child relationships are defined via `parentId` and `children` arrays.
---
### Scene State (Zustand Store)
The scene is managed by a Zustand store in `@pascal-app/core`:
```typescript
useScene.getState() = {
nodes: Record<id, AnyNode>, // All nodes
rootNodeIds: string[], // Top-level nodes (sites)
dirtyNodes: Set<string>, // Nodes pending system updates
createNode(node, parentId),
updateNode(id, updates),
deleteNode(id),
}
```
**Middleware:**
- **Persist** - Saves to IndexedDB (excludes transient nodes)
- **Temporal** (Zundo) - Undo/redo with 50-step history
---
### Scene Registry
The registry maps node IDs to their Three.js objects for fast lookup:
```typescript
sceneRegistry = {
nodes: Map<id, Object3D>, // ID → 3D object
byType: {
wall: Set<id>,
item: Set<id>,
zone: Set<id>,
// ...
}
}
```
Renderers register their refs using the `useRegistry` hook:
```tsx
const ref = useRef<Mesh>(null!)
useRegistry(node.id, 'wall', ref)
```
This allows systems to access 3D objects directly without traversing the scene graph.
---
### Node Renderers
Renderers are React components that create Three.js objects for each node type:
```
SceneRenderer
└── NodeRenderer (dispatches by type)
├── BuildingRenderer
├── LevelRenderer
├── WallRenderer
├── SlabRenderer
├── ZoneRenderer
├── ItemRenderer
└── ...
```
**Pattern:**
1. Renderer creates a placeholder mesh/group
2. Registers it with `useRegistry`
3. Systems update geometry based on node data
Example (simplified):
```tsx
const WallRenderer = ({ node }) => {
const ref = useRef<Mesh>(null!)
useRegistry(node.id, 'wall', ref)
return (
<mesh ref={ref}>
<boxGeometry args={[0, 0, 0]} /> {/* Replaced by WallSystem */}
<meshStandardMaterial />
{node.children.map(id => <NodeRenderer key={id} nodeId={id} />)}
</mesh>
)
}
```
---
### Systems
Systems are React components that run in the render loop (`useFrame`) to update geometry and transforms. They process **dirty nodes** marked by the store.
**Core Systems (in `@pascal-app/core`):**
| System | Responsibility |
|--------|---------------|
| `WallSystem` | Generates wall geometry with mitering and CSG cutouts for doors/windows |
| `SlabSystem` | Generates floor geometry from polygons |
| `CeilingSystem` | Generates ceiling geometry |
| `RoofSystem` | Generates roof geometry |
| `ItemSystem` | Positions items on walls, ceilings, or floors (slab elevation) |
**Viewer Systems (in `@pascal-app/viewer`):**
| System | Responsibility |
|--------|---------------|
| `LevelSystem` | Handles level visibility and vertical positioning (stacked/exploded/solo modes) |
| `ScanSystem` | Controls 3D scan visibility |
| `GuideSystem` | Controls guide image visibility |
**Processing Pattern:**
```typescript
useFrame(() => {
for (const id of dirtyNodes) {
const obj = sceneRegistry.nodes.get(id)
const node = useScene.getState().nodes[id]
// Update geometry, transforms, etc.
updateGeometry(obj, node)
dirtyNodes.delete(id)
}
})
```
---
### Dirty Nodes
When a node changes, it's marked as **dirty** in `useScene.getState().dirtyNodes`. Systems check this set each frame and only recompute geometry for dirty nodes.
```typescript
// Automatic: createNode, updateNode, deleteNode mark nodes dirty
useScene.getState().updateNode(wallId, { thickness: 0.2 })
// → wallId added to dirtyNodes
// → WallSystem regenerates geometry next frame
// → wallId removed from dirtyNodes
```
**Manual marking:**
```typescript
useScene.getState().dirtyNodes.add(wallId)
```
---
### Event Bus
Inter-component communication uses a typed event emitter (mitt):
```typescript
// Node events
emitter.on('wall:click', (event) => { ... })
emitter.on('item:enter', (event) => { ... })
emitter.on('zone:context-menu', (event) => { ... })
// Grid events (background)
emitter.on('grid:click', (event) => { ... })
// Event payload
NodeEvent {
node: AnyNode
position: [x, y, z]
localPosition: [x, y, z]
normal?: [x, y, z]
stopPropagation: () => void
}
```
---
### Spatial Grid Manager
Handles collision detection and placement validation:
```typescript
spatialGridManager.canPlaceOnFloor(levelId, position, dimensions, rotation)
spatialGridManager.canPlaceOnWall(wallId, t, height, dimensions)
spatialGridManager.getSlabElevationAt(levelId, x, z)
```
Used by item placement tools to validate positions and calculate slab elevations.
---
## Editor Architecture
The editor extends the viewer with:
### Tools
Tools are activated via the toolbar and handle user input for specific operations:
- **SelectTool** - Selection and manipulation
- **WallTool** - Draw walls
- **ZoneTool** - Create zones
- **ItemTool** - Place furniture/fixtures
- **SlabTool** - Create floor slabs
### Selection Manager
The editor uses a custom selection manager with hierarchical navigation:
```
Site → Building → Level → Zone → Items
```
Each depth level has its own selection strategy for hover/click behavior.
### Editor-Specific Systems
- `ZoneSystem` - Controls zone visibility based on level mode
- Custom camera controls with node focusing
---
## Data Flow
```
User Action (click, drag)
↓
Tool Handler
↓
useScene.createNode() / updateNode()
↓
Node added/updated in store
Node marked dirty
↓
React re-renders NodeRenderer
useRegistry() registers 3D object
↓
System detects dirty node (useFrame)
Updates geometry via sceneRegistry
Clears dirty flag
```
---
## Technology Stack
- **React 19** + **Next.js 15**
- **Three.js** (WebGPU renderer)
- **React Three Fiber** + **Drei**
- **Zustand** (state management)
- **Zod** (schema validation)
- **Zundo** (undo/redo)
- **three-bvh-csg** (Boolean geometry operations)
---
## Getting Started
```bash
# Install dependencies
pnpm install
# Run development server
pnpm dev
# Open http://localhost:3000
```
---
## Key Files
| Path | Description |
|------|-------------|
| `packages/core/src/schema/` | Node type definitions (Zod schemas) |
| `packages/core/src/hooks/use-scene.ts` | Scene state store |
| `packages/core/src/hooks/scene-registry/` | 3D object registry |
| `packages/core/src/systems/` | Geometry generation systems |
| `packages/viewer/src/components/renderers/` | Node renderers |
| `packages/viewer/src/components/viewer/` | Main Viewer component |
| `apps/editor/components/tools/` | Editor tools |
| `apps/editor/store/` | Editor-specific state |

View File

@ -0,0 +1,161 @@
import { createOpenAI } from '@ai-sdk/openai'
import { convertToModelMessages, stepCountIs, streamText, type UIMessage } from 'ai'
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { validatePascalAgentAccess } from '@/lib/pascal-agent-access'
import { getPascalAgentConfig } from '@/lib/pascal-agent-config'
import { createPascalAgentMcpSession } from '@/lib/pascal-agent-mcp'
import { buildPascalAgentPrompt } from '@/lib/pascal-agent-prompt'
import { getPascalHostIdentity } from '@/lib/pascal-host-identity'
import { getSceneOperations } from '@/lib/scene-store-server'
export const dynamic = 'force-dynamic'
export const maxDuration = 600
export const runtime = 'nodejs'
const requestSchema = z.object({
messages: z.array(z.unknown()).min(1).max(100),
sceneId: z
.string()
.min(1)
.max(64)
.regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),
})
const AGENT_ERROR_MESSAGE = 'AI design agent is temporarily unavailable. Please try again.'
const SCENE_ERROR_MESSAGE = 'The current scene could not be loaded.'
function accessErrorResponse(result: ReturnType<typeof validatePascalAgentAccess>) {
if (result === 'missing_proxy_secret') {
return NextResponse.json({ error: 'agent_proxy_not_configured' }, { status: 503 })
}
if (result === 'unauthorized') {
return NextResponse.json({ error: 'unauthorized' }, { status: 401 })
}
return null
}
export function GET(request: Request) {
const accessError = accessErrorResponse(validatePascalAgentAccess(request.headers))
if (accessError) return accessError
const identity = getPascalHostIdentity(request.headers)
if (!identity.allowed) {
return NextResponse.json({ error: 'host_user_required' }, { status: 401 })
}
const config = getPascalAgentConfig()
return NextResponse.json({
configured: config.configured,
maxSteps: config.maxSteps,
model: config.model,
})
}
export async function POST(request: Request) {
const accessError = accessErrorResponse(validatePascalAgentAccess(request.headers))
if (accessError) return accessError
const identity = getPascalHostIdentity(request.headers)
if (!identity.allowed) {
return NextResponse.json({ error: 'host_user_required' }, { status: 401 })
}
const config = getPascalAgentConfig()
if (!config.configured) {
return NextResponse.json(
{
error: 'agent_not_configured',
message: 'Configure PASCAL_AGENT_API_KEY before using the AI design agent.',
},
{ status: 503 },
)
}
let body: unknown
try {
body = await request.json()
} catch {
return NextResponse.json({ error: 'invalid_json' }, { status: 400 })
}
const parsed = requestSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json(
{ error: 'invalid_request', details: parsed.error.issues },
{ status: 400 },
)
}
const operations = await getSceneOperations()
const storedScene = await operations.loadStoredScene(parsed.data.sceneId)
if (!storedScene || (identity.userId && storedScene.ownerId !== identity.userId)) {
return NextResponse.json(
{ error: 'scene_unavailable', message: SCENE_ERROR_MESSAGE },
{ status: 404 },
)
}
const abortSignal = AbortSignal.any([request.signal, AbortSignal.timeout(config.totalTimeoutMs)])
let session: Awaited<ReturnType<typeof createPascalAgentMcpSession>>
try {
session = await createPascalAgentMcpSession({
abortSignal,
sceneId: parsed.data.sceneId,
stepTimeoutMs: config.stepTimeoutMs,
})
} catch (error) {
console.error('Pascal agent could not load the requested scene.', error)
return NextResponse.json(
{ error: 'scene_unavailable', message: SCENE_ERROR_MESSAGE },
{ status: 404 },
)
}
const provider = createOpenAI({
apiKey: config.apiKey,
baseURL: config.baseUrl,
name: 'pascal-agent',
})
let finalized = false
const finalize = async (save: boolean) => {
if (finalized) return
finalized = true
try {
if (save && !abortSignal.aborted) {
await session.saveDraft(abortSignal)
}
} finally {
await session.close()
}
}
try {
const result = streamText({
abortSignal,
maxOutputTokens: 1600,
messages: convertToModelMessages(parsed.data.messages as UIMessage[]),
model: provider.chat(config.model),
onAbort: async () => finalize(false),
onError: async () => finalize(false),
onFinish: async () => finalize(true),
stopWhen: stepCountIs(config.maxSteps),
system: buildPascalAgentPrompt({
sceneId: parsed.data.sceneId,
sceneName: session.sceneName,
}),
temperature: 0.2,
tools: session.tools,
})
return result.toUIMessageStreamResponse({
onError: (error) => {
console.error('Pascal agent stream failed.', error)
return AGENT_ERROR_MESSAGE
},
})
} catch (error) {
await finalize(false)
console.error('Pascal agent failed before streaming.', error)
return NextResponse.json(
{ error: 'agent_failed', message: AGENT_ERROR_MESSAGE },
{ status: 500 },
)
}
}

View File

@ -0,0 +1,3 @@
export function GET() {
return Response.json({ status: 'ok', app: 'editor', timestamp: new Date().toISOString() })
}

View File

@ -0,0 +1,124 @@
import { getPascalHostIdentity } from '@/lib/pascal-host-identity'
import {
guardSceneApiRequest,
sceneApiJson,
sceneApiPreflight,
withSceneApiHeaders,
} from '@/lib/scene-api-security'
import { getSceneOperations } from '@/lib/scene-store-server'
export const dynamic = 'force-dynamic'
export const runtime = 'nodejs'
type RouteParams = { params: Promise<{ id: string }> }
const POLL_MS = 250
const HEARTBEAT_MS = 15_000
const MAX_EVENTS_PER_POLL = 50
export function OPTIONS(request: Request) {
return sceneApiPreflight(request)
}
export async function GET(request: Request, { params }: RouteParams) {
const guard = guardSceneApiRequest(request)
if (guard) return guard
const identity = getPascalHostIdentity(request.headers)
if (!identity.allowed) {
return sceneApiJson(request, { error: 'host_user_required' }, { status: 401 })
}
const { id } = await params
const operations = await getSceneOperations()
if (!operations.canListSceneEvents) {
return sceneApiJson(request, { error: 'scene_events_unavailable' }, { status: 501 })
}
const scene = await operations.loadStoredScene(id)
if (!scene) {
return sceneApiJson(request, { error: 'not_found' }, { status: 404 })
}
if (identity.userId && scene.ownerId !== identity.userId) {
return sceneApiJson(request, { error: 'not_found' }, { status: 404 })
}
const url = new URL(request.url)
const afterFromQuery = Number.parseInt(url.searchParams.get('after') ?? '0', 10)
const afterFromHeader = Number.parseInt(request.headers.get('Last-Event-ID') ?? '0', 10)
let cursor = Math.max(
0,
Number.isFinite(afterFromQuery) ? afterFromQuery : 0,
Number.isFinite(afterFromHeader) ? afterFromHeader : 0,
)
const encoder = new TextEncoder()
let closed = false
let pollTimer: ReturnType<typeof setTimeout> | undefined
let heartbeatTimer: ReturnType<typeof setInterval> | undefined
const stream = new ReadableStream<Uint8Array>({
start(controller) {
const enqueue = (chunk: string) => {
if (!closed) controller.enqueue(encoder.encode(chunk))
}
const close = () => {
if (closed) return
closed = true
if (pollTimer) clearTimeout(pollTimer)
if (heartbeatTimer) clearInterval(heartbeatTimer)
try {
controller.close()
} catch {
// The client may have already closed the stream.
}
}
request.signal.addEventListener('abort', close, { once: true })
enqueue('retry: 1000\n\n')
const poll = async () => {
if (closed) return
try {
const events = await operations.listSceneEvents(id, {
afterEventId: cursor,
limit: MAX_EVENTS_PER_POLL,
})
for (const event of events) {
cursor = event.eventId
enqueue(`id: ${event.eventId}\n`)
enqueue('event: scene\n')
enqueue(`data: ${JSON.stringify(event)}\n\n`)
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
enqueue('event: error\n')
enqueue(`data: ${JSON.stringify({ message })}\n\n`)
} finally {
if (!closed) pollTimer = setTimeout(poll, POLL_MS)
}
}
heartbeatTimer = setInterval(() => enqueue(': keepalive\n\n'), HEARTBEAT_MS)
void poll()
},
cancel() {
closed = true
if (pollTimer) clearTimeout(pollTimer)
if (heartbeatTimer) clearInterval(heartbeatTimer)
},
})
return withSceneApiHeaders(
request,
new Response(stream, {
headers: {
'Cache-Control': 'no-cache, no-transform',
Connection: 'keep-alive',
'Content-Type': 'text/event-stream; charset=utf-8',
'X-Accel-Buffering': 'no',
},
}),
)
}

View File

@ -0,0 +1,253 @@
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { apiGraphSchema } from '@/lib/graph-schema'
import { getPascalHostIdentity, type PascalHostIdentity } from '@/lib/pascal-host-identity'
import {
guardSceneApiRequest,
sceneApiJson,
sceneApiPreflight,
withSceneApiHeaders,
} from '@/lib/scene-api-security'
import { getSceneOperations } from '@/lib/scene-store-server'
export const dynamic = 'force-dynamic'
type RouteParams = { params: Promise<{ id: string }> }
const putSceneSchema = z.object({
name: z.string().min(1).max(200).optional(),
graph: apiGraphSchema,
thumbnailUrl: z.string().url().nullable().optional(),
expectedVersion: z.number().int().nonnegative().optional(),
})
const patchSceneSchema = z.object({
name: z.string().min(1).max(200),
expectedVersion: z.number().int().nonnegative().optional(),
})
function hostIdentityError(request: NextRequest, identity: PascalHostIdentity) {
return identity.allowed
? null
: sceneApiJson(request, { error: 'host_user_required' }, { status: 401 })
}
function canAccessScene(identity: PascalHostIdentity, ownerId: string | null): boolean {
return !identity.userId || ownerId === identity.userId
}
export function OPTIONS(request: NextRequest) {
return sceneApiPreflight(request)
}
export async function GET(request: NextRequest, { params }: RouteParams) {
const guard = guardSceneApiRequest(request)
if (guard) return guard
const identity = getPascalHostIdentity(request.headers)
const identityError = hostIdentityError(request, identity)
if (identityError) return identityError
const { id } = await params
const operations = await getSceneOperations()
try {
const scene = await operations.loadStoredScene(id)
if (!scene) {
return sceneApiJson(request, { error: 'not_found' }, { status: 404 })
}
if (!canAccessScene(identity, scene.ownerId)) {
return sceneApiJson(request, { error: 'not_found' }, { status: 404 })
}
return sceneApiJson(request, scene, {
headers: { ETag: `"${scene.version}"` },
})
} catch (error) {
return handleStoreError(request, error)
}
}
export async function PUT(request: NextRequest, { params }: RouteParams) {
const guard = guardSceneApiRequest(request)
if (guard) return guard
const identity = getPascalHostIdentity(request.headers)
const identityError = hostIdentityError(request, identity)
if (identityError) return identityError
const { id } = await params
let body: unknown
try {
body = await request.json()
} catch {
return sceneApiJson(
request,
{ error: 'invalid_request', details: 'body must be valid JSON' },
{ status: 400 },
)
}
const parsed = putSceneSchema.safeParse(body)
if (!parsed.success) {
return sceneApiJson(
request,
{ error: 'invalid_request', details: parsed.error.issues },
{ status: 400 },
)
}
const ifMatch = parseIfMatch(request.headers.get('If-Match'))
const expectedVersion = ifMatch ?? parsed.data.expectedVersion
const operations = await getSceneOperations()
try {
const existing = await operations.loadStoredScene(id)
if (!existing) {
return sceneApiJson(request, { error: 'not_found' }, { status: 404 })
}
if (!canAccessScene(identity, existing.ownerId)) {
return sceneApiJson(request, { error: 'not_found' }, { status: 404 })
}
const meta = await operations.saveScene({
id,
name: parsed.data.name ?? existing.name,
projectId: existing.projectId,
ownerId: existing.ownerId,
graph: parsed.data.graph as never,
thumbnailUrl:
parsed.data.thumbnailUrl === undefined ? existing.thumbnailUrl : parsed.data.thumbnailUrl,
expectedVersion: expectedVersion ?? existing.version,
})
return sceneApiJson(request, meta, {
headers: { ETag: `"${meta.version}"` },
})
} catch (error) {
return handleStoreError(request, error, { includeCurrentVersionFor: id })
}
}
export async function DELETE(request: NextRequest, { params }: RouteParams) {
const guard = guardSceneApiRequest(request)
if (guard) return guard
const identity = getPascalHostIdentity(request.headers)
const identityError = hostIdentityError(request, identity)
if (identityError) return identityError
const { id } = await params
const ifMatch = parseIfMatch(request.headers.get('If-Match'))
const operations = await getSceneOperations()
try {
const existing = await operations.loadStoredScene(id)
if (!existing || !canAccessScene(identity, existing.ownerId)) {
return sceneApiJson(request, { error: 'not_found' }, { status: 404 })
}
const removed = await operations.deleteStoredScene(id, { expectedVersion: ifMatch })
if (!removed) {
return sceneApiJson(request, { error: 'not_found' }, { status: 404 })
}
return withSceneApiHeaders(request, new NextResponse(null, { status: 204 }))
} catch (error) {
return handleStoreError(request, error, { includeCurrentVersionFor: id })
}
}
export async function PATCH(request: NextRequest, { params }: RouteParams) {
const guard = guardSceneApiRequest(request)
if (guard) return guard
const identity = getPascalHostIdentity(request.headers)
const identityError = hostIdentityError(request, identity)
if (identityError) return identityError
const { id } = await params
let body: unknown
try {
body = await request.json()
} catch {
return sceneApiJson(
request,
{ error: 'invalid_request', details: 'body must be valid JSON' },
{ status: 400 },
)
}
const parsed = patchSceneSchema.safeParse(body)
if (!parsed.success) {
return sceneApiJson(
request,
{ error: 'invalid_request', details: parsed.error.issues },
{ status: 400 },
)
}
const ifMatch = parseIfMatch(request.headers.get('If-Match'))
const expectedVersion = ifMatch ?? parsed.data.expectedVersion
const operations = await getSceneOperations()
try {
const existing = await operations.loadStoredScene(id)
if (!existing || !canAccessScene(identity, existing.ownerId)) {
return sceneApiJson(request, { error: 'not_found' }, { status: 404 })
}
const meta = await operations.renameStoredScene(id, parsed.data.name, { expectedVersion })
return sceneApiJson(request, meta, {
headers: { ETag: `"${meta.version}"` },
})
} catch (error) {
return handleStoreError(request, error, { includeCurrentVersionFor: id })
}
}
/**
* Parses an `If-Match` header value per RFC 7232. Accepts `"<version>"` or
* weak `W/"<version>"` forms. Returns `undefined` when the header is absent,
* the wildcard `*`, or unparseable as a non-negative integer.
*/
function parseIfMatch(raw: string | null): number | undefined {
if (!raw) return undefined
const trimmed = raw.trim()
if (trimmed === '*') return undefined
const match = trimmed.match(/^(?:W\/)?"([^"]+)"$/)
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
return n
}
async function handleStoreError(
request: NextRequest,
error: unknown,
opts: { includeCurrentVersionFor?: string } = {},
): Promise<NextResponse> {
const code = (error as { code?: string })?.code
if (code === 'version_conflict') {
let currentVersion: number | undefined
if (opts.includeCurrentVersionFor) {
try {
const operations = await getSceneOperations()
const current = await operations.loadStoredScene(opts.includeCurrentVersionFor)
currentVersion = current?.version
} catch {
// Best-effort; skip reporting currentVersion on secondary failure.
}
}
return sceneApiJson(
request,
currentVersion === undefined
? { error: 'version_conflict' }
: { error: 'version_conflict', currentVersion },
{ status: 409 },
)
}
if (code === 'not_found') {
return sceneApiJson(request, { error: 'not_found' }, { status: 404 })
}
if (code === 'too_large') {
return sceneApiJson(request, { error: 'too_large' }, { status: 413 })
}
if (code === 'invalid') {
return sceneApiJson(request, { error: 'invalid' }, { status: 400 })
}
const message = error instanceof Error ? error.message : 'unexpected_error'
return sceneApiJson(request, { error: 'internal_error', message }, { status: 500 })
}

View File

@ -0,0 +1,121 @@
import type { NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { apiGraphSchema } from '@/lib/graph-schema'
import { withPascalBasePath } from '@/lib/pascal-base-path'
import { getPascalHostIdentity } from '@/lib/pascal-host-identity'
import { guardSceneApiRequest, sceneApiJson, sceneApiPreflight } from '@/lib/scene-api-security'
import { getSceneOperations } from '@/lib/scene-store-server'
export const dynamic = 'force-dynamic'
const createSceneSchema = z.object({
id: z.string().min(1).max(64).optional(),
name: z.string().min(1).max(200),
projectId: z.string().min(1).max(200).nullable().optional(),
graph: apiGraphSchema,
thumbnailUrl: z.string().url().nullable().optional(),
})
const listQuerySchema = z.object({
projectId: z.string().min(1).max(200).optional(),
limit: z.coerce.number().int().positive().max(500).optional(),
})
export function OPTIONS(request: NextRequest) {
return sceneApiPreflight(request)
}
export async function GET(request: NextRequest) {
const guard = guardSceneApiRequest(request)
if (guard) return guard
const identity = getPascalHostIdentity(request.headers)
if (!identity.allowed) {
return sceneApiJson(request, { error: 'host_user_required' }, { status: 401 })
}
const url = new URL(request.url)
const parsed = listQuerySchema.safeParse({
projectId: url.searchParams.get('projectId') ?? undefined,
limit: url.searchParams.get('limit') ?? undefined,
})
if (!parsed.success) {
return sceneApiJson(
request,
{ error: 'invalid_request', details: parsed.error.issues },
{ status: 400 },
)
}
const operations = await getSceneOperations()
const scenes = await operations.listScenes({
ownerId: identity.userId ?? undefined,
projectId: parsed.data.projectId,
limit: parsed.data.limit,
})
return sceneApiJson(request, { scenes })
}
export async function POST(request: NextRequest) {
const guard = guardSceneApiRequest(request)
if (guard) return guard
const identity = getPascalHostIdentity(request.headers)
if (!identity.allowed) {
return sceneApiJson(request, { error: 'host_user_required' }, { status: 401 })
}
let body: unknown
try {
body = await request.json()
} catch {
return sceneApiJson(
request,
{ error: 'invalid_request', details: 'body must be valid JSON' },
{ status: 400 },
)
}
const parsed = createSceneSchema.safeParse(body)
if (!parsed.success) {
return sceneApiJson(
request,
{ error: 'invalid_request', details: parsed.error.issues },
{ status: 400 },
)
}
const operations = await getSceneOperations()
try {
const meta = await operations.saveScene({
id: parsed.data.id,
name: parsed.data.name,
ownerId: identity.userId,
projectId: parsed.data.projectId ?? null,
graph: parsed.data.graph as never,
thumbnailUrl: parsed.data.thumbnailUrl ?? null,
})
return sceneApiJson(request, meta, {
status: 201,
headers: { Location: withPascalBasePath(`/scene/${meta.id}`) },
})
} catch (error) {
return handleStoreError(request, error)
}
}
function handleStoreError(request: NextRequest, error: unknown): NextResponse {
const code = (error as { code?: string })?.code
if (code === 'version_conflict') {
return sceneApiJson(request, { error: 'version_conflict' }, { status: 409 })
}
if (code === 'not_found') {
return sceneApiJson(request, { error: 'not_found' }, { status: 404 })
}
if (code === 'too_large') {
return sceneApiJson(request, { error: 'too_large' }, { status: 413 })
}
if (code === 'invalid') {
return sceneApiJson(request, { error: 'invalid' }, { status: 400 })
}
const message = error instanceof Error ? error.message : 'unexpected_error'
return sceneApiJson(request, { error: 'internal_error', message }, { status: 500 })
}

View File

@ -0,0 +1,23 @@
'use client'
// Loads `@pascal-app/nodes`' built-in plugin into the node registry on the
// client. Mounted from `layout.tsx` so every page in the standalone
// editor gets the registry populated before its first `<Viewer>` /
// `<Editor>` mounts — without this the registry is empty on the client
// (the server registers in its own module instance, which is unreachable
// from hydrated pages) and every `NodeRenderer` resolves to `null`. The
// `loaded` guard inside `../lib/bootstrap` keeps the side effect
// idempotent under HMR.
import '../lib/bootstrap'
import { type ReactNode, useEffect } from 'react'
export function ClientBootstrap({ children }: { children: ReactNode }) {
useEffect(() => {
if (process.env.NODE_ENV !== 'development') return
// Loaded here (not via a `<Script>` tag in <head>) to avoid React's
// "script inside a React component" hydration warning. The package
// is already a direct dep, so we don't need the CDN auto-global.
import('react-scan').then(({ scan }) => scan({ enabled: true }))
}, [])
return children
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

@ -0,0 +1,369 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "../../../styles/elevation.css";
@source "../../../packages/editor/src";
@custom-variant dark (&:is(.dark *));
@theme {
--font-sans:
var(--font-barlow), var(--font-geist-sans), ui-sans-serif, system-ui,
sans-serif;
--font-mono:
var(--font-geist-mono), ui-monospace, SFMono-Regular, Menlo, Monaco,
Consolas, monospace;
--font-pixel:
var(--font-geist-pixel-square), var(--font-geist-mono), ui-monospace,
SFMono-Regular, Menlo, Monaco, Consolas, monospace;
--font-barlow:
var(--font-barlow), var(--font-geist-sans), ui-sans-serif, system-ui,
sans-serif;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-barlow), sans-serif;
--font-mono: var(--font-geist-mono), monospace;
--font-pixel:
var(--font-geist-pixel-square), var(--font-geist-mono), monospace;
--font-barlow: var(--font-barlow), sans-serif;
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
}
:root {
--radius: 0.625rem;
--background: oklch(0.998 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(0.998 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(0.998 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.205 0 0); /* ~171717 */
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(
0.235 0 0
); /* slightly lighter than background (0.205) but darker than previous (0.269) */
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.235 0 0); /* matching accent */
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
button,
[role="button"],
a {
cursor: pointer;
}
}
/* Apple-style smooth corners (squircle) — progressive enhancement */
.rounded-smooth {
border-radius: var(--radius-lg);
corner-shape: squircle;
}
.rounded-smooth-xl {
border-radius: var(--radius-xl);
corner-shape: squircle;
}
.no-scrollbar::-webkit-scrollbar {
display: none;
}
.no-scrollbar {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
/* Loaders */
.pascal-loader-1 {
width: 45px;
aspect-ratio: 1;
--c: no-repeat linear-gradient(currentColor 0 0);
background: var(--c), var(--c), var(--c);
animation:
pascal-l1-1 1s infinite,
pascal-l1-2 1s infinite;
}
@keyframes pascal-l1-1 {
0%,
100% {
background-size: 20% 100%;
}
33%,
66% {
background-size: 20% 20%;
}
}
@keyframes pascal-l1-2 {
0%,
33% {
background-position:
0 0,
50% 50%,
100% 100%;
}
66%,
100% {
background-position:
100% 0,
50% 50%,
0 100%;
}
}
.pascal-loader-2 {
width: 45px;
aspect-ratio: 0.75;
--c: no-repeat linear-gradient(currentColor 0 0);
background:
var(--c) 0% 50%,
var(--c) 50% 50%,
var(--c) 100% 50%;
background-size: 20% 50%;
animation: pascal-l2 1s infinite linear;
}
@keyframes pascal-l2 {
20% {
background-position:
0% 0%,
50% 50%,
100% 50%;
}
40% {
background-position:
0% 100%,
50% 0%,
100% 50%;
}
60% {
background-position:
0% 50%,
50% 100%,
100% 0%;
}
80% {
background-position:
0% 50%,
50% 50%,
100% 100%;
}
}
.pascal-loader-3 {
width: 45px;
aspect-ratio: 0.75;
--c: no-repeat linear-gradient(currentColor 0 0);
background:
var(--c) 0% 100%,
var(--c) 50% 100%,
var(--c) 100% 100%;
background-size: 20% 65%;
animation: pascal-l3 1s infinite linear;
}
@keyframes pascal-l3 {
16.67% {
background-position:
0% 0%,
50% 100%,
100% 100%;
}
33.33% {
background-position:
0% 0%,
50% 0%,
100% 100%;
}
50% {
background-position:
0% 0%,
50% 0%,
100% 0%;
}
66.67% {
background-position:
0% 100%,
50% 0%,
100% 0%;
}
83.33% {
background-position:
0% 100%,
50% 100%,
100% 0%;
}
}
.pascal-loader-4 {
width: 45px;
aspect-ratio: 1;
--c: no-repeat linear-gradient(currentColor 0 0);
background: var(--c), var(--c), var(--c);
animation:
pascal-l4-1 1s infinite,
pascal-l4-2 1s infinite;
}
@keyframes pascal-l4-1 {
0%,
100% {
background-size: 20% 100%;
}
33%,
66% {
background-size: 20% 40%;
}
}
@keyframes pascal-l4-2 {
0%,
33% {
background-position:
0 0,
50% 100%,
100% 100%;
}
66%,
100% {
background-position:
100% 0,
0 100%,
50% 100%;
}
}
.pascal-loader-5 {
width: 45px;
aspect-ratio: 1;
--c: no-repeat linear-gradient(currentColor 0 0);
background: var(--c), var(--c), var(--c);
animation:
pascal-l5-1 1s infinite,
pascal-l5-2 1s infinite;
}
@keyframes pascal-l5-1 {
0%,
100% {
background-size: 20% 100%;
}
33%,
66% {
background-size: 20% 40%;
}
}
@keyframes pascal-l5-2 {
0%,
33% {
background-position:
0 0,
50% 100%,
100% 0;
}
66%,
100% {
background-position:
0 100%,
50% 0,
100% 100%;
}
}

View File

@ -0,0 +1,45 @@
import { Agentation } from 'agentation'
import { GeistPixelSquare } from 'geist/font/pixel'
import { Barlow } from 'next/font/google'
import localFont from 'next/font/local'
import { ClientBootstrap } from './client-bootstrap'
import './globals.css'
const geistSans = localFont({
src: './fonts/GeistVF.woff',
variable: '--font-geist-sans',
})
const geistMono = localFont({
src: './fonts/GeistMonoVF.woff',
variable: '--font-geist-mono',
})
const barlow = Barlow({
subsets: ['latin'],
weight: ['400', '500', '600', '700'],
variable: '--font-barlow',
display: 'swap',
})
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode
}>) {
return (
<html
className={`${geistSans.variable} ${geistMono.variable} ${GeistPixelSquare.variable} ${barlow.variable}`}
lang="zh-CN"
>
<head>
{process.env.NODE_ENV === 'development' && (
<script async crossOrigin="anonymous" src="//unpkg.com/react-scan/dist/auto.global.js" />
)}
</head>
<body className="font-sans">
<ClientBootstrap>{children}</ClientBootstrap>
{process.env.NODE_ENV === 'development' && <Agentation />}
</body>
</html>
)
}

View File

@ -0,0 +1,118 @@
'use client'
import { Editor, ItemsPanel } from '@pascal-app/editor'
import { Hammer, Layers, Package, Settings } from 'lucide-react'
import Image from 'next/image'
import Link from 'next/link'
import { BuildTab } from '@/components/build-tab'
import {
CommunityViewerToolbarLeft,
CommunityViewerToolbarRight,
} from '@/components/viewer-toolbar'
import { withPascalBasePath } from '@/lib/pascal-base-path'
// The open-source editor only ships the built-in catalog (no uploaded items),
// so the Library/Community/Mine source chips and tag filters add nothing —
// drop them and keep the panel to plain categories.
function EditorItemsPanel() {
return <ItemsPanel showSourceFilter={false} showTagFilters={false} />
}
const SIDEBAR_TABS = [
{
id: 'site',
label: '场景',
component: () => null,
mobileDefaultSnap: 0.5,
mobileIcon: <Layers className="h-5 w-5" />,
icon: (
<Image
alt=""
className="h-8 w-8 object-contain"
height={32}
src={withPascalBasePath('/icons/scene.webp')}
width={32}
/>
),
},
{
id: 'build',
label: '搭建',
component: BuildTab,
mobileDefaultSnap: 0.5,
mobileIcon: <Hammer className="h-5 w-5" />,
icon: (
<Image
alt=""
className="h-8 w-8 object-contain"
height={32}
src={withPascalBasePath('/icons/build.webp')}
width={32}
/>
),
},
{
id: 'items',
label: '物品',
component: EditorItemsPanel,
mobileDefaultSnap: 0.5,
mobileIcon: <Package className="h-5 w-5" />,
icon: (
<Image
alt=""
className="h-8 w-8 object-contain"
height={32}
src={withPascalBasePath('/icons/couch.webp')}
width={32}
/>
),
},
{
id: 'settings',
label: '设置',
component: () => null,
mobileDefaultSnap: 0.5,
mobileIcon: <Settings className="h-5 w-5" />,
icon: (
<Image
alt=""
className="h-8 w-8 object-contain"
height={32}
src={withPascalBasePath('/icons/settings.webp')}
width={32}
/>
),
},
]
const PROJECT_ID = 'local-editor'
export default function Home() {
return (
<div className="relative h-screen w-screen">
{PROJECT_ID === 'local-editor' && (
<div className="pointer-events-none absolute top-3 left-1/2 z-40 -translate-x-1/2">
<div className="pointer-events-auto flex items-center gap-3 rounded-full border border-border/60 bg-background/90 px-4 py-1.5 text-xs shadow-sm backdrop-blur">
<span className="text-muted-foreground">本地编辑器中的场景不会自动保存。</span>
<Link className="font-medium text-foreground hover:underline" href="/scenes">
打开最近场景
</Link>
<span aria-hidden className="text-muted-foreground">
·
</span>
<Link className="font-medium text-foreground hover:underline" href="/scenes">
新建场景
</Link>
</div>
</div>
)}
<Editor
layoutVersion="v2"
projectId={PROJECT_ID}
sidebarTabs={SIDEBAR_TABS}
viewerToolbarLeft={<CommunityViewerToolbarLeft />}
viewerToolbarRight={<CommunityViewerToolbarRight />}
/>
</div>
)
}

View File

@ -0,0 +1,207 @@
import type { Metadata } from 'next'
import Link from 'next/link'
export const metadata: Metadata = {
title: 'Privacy Policy',
description: 'Privacy Policy for Pascal Editor and the Pascal platform.',
}
export default function PrivacyPage() {
return (
<div className="min-h-screen bg-background">
<header className="sticky top-0 z-10 border-border border-b bg-background/95 backdrop-blur">
<div className="container mx-auto px-6 py-4">
<nav className="flex items-center gap-4 text-sm">
<Link
className="text-muted-foreground transition-colors hover:text-foreground"
href="/"
>
Home
</Link>
<span className="text-muted-foreground">/</span>
<Link
className="text-muted-foreground transition-colors hover:text-foreground"
href="/terms"
>
Terms of Service
</Link>
<span className="text-muted-foreground">|</span>
<span className="font-medium text-foreground">Privacy Policy</span>
</nav>
</div>
</header>
<main className="container mx-auto max-w-3xl px-6 py-12">
<article className="prose prose-neutral dark:prose-invert max-w-none">
<h1 className="mb-2 font-bold text-3xl">Privacy Policy</h1>
<p className="mb-8 text-muted-foreground text-sm">Effective Date: February 20, 2026</p>
<section className="mb-8 space-y-4">
<h2 className="font-semibold text-xl">1. Introduction</h2>
<p className="text-foreground/90 leading-relaxed">
Pascal Group Inc. (&quot;we,&quot; &quot;us,&quot; or &quot;our&quot;) operates the
Pascal Editor and Platform at pascal.app. This Privacy Policy explains how we collect,
use, and protect your information when you use our services.
</p>
</section>
<section className="mb-8 space-y-4">
<h2 className="font-semibold text-xl">2. Information We Collect</h2>
<h3 className="mt-4 font-medium text-lg">Account Information</h3>
<p className="text-foreground/90 leading-relaxed">
When you create an account, we collect:
</p>
<ul className="list-disc space-y-2 pl-6 text-foreground/90">
<li>Email address</li>
<li>Name</li>
<li>Profile picture/avatar</li>
<li>OAuth provider data (from Google when you sign in with Google)</li>
</ul>
<h3 className="mt-4 font-medium text-lg">Project Data</h3>
<p className="text-foreground/90 leading-relaxed">
When you use the Platform, we store your projects, including 3D building designs,
floor plans, and associated metadata.
</p>
<h3 className="mt-4 font-medium text-lg">Usage Analytics</h3>
<p className="text-foreground/90 leading-relaxed">
We use Vercel Analytics and Speed Insights to collect anonymized usage data, including
page views, performance metrics, and general usage patterns. This helps us improve the
Platform.
</p>
</section>
<section className="mb-8 space-y-4">
<h2 className="font-semibold text-xl">3. How We Use Your Information</h2>
<p className="text-foreground/90 leading-relaxed">We use your information to:</p>
<ul className="list-disc space-y-2 pl-6 text-foreground/90">
<li>Provide and maintain your account</li>
<li>Store and sync your projects across devices</li>
<li>Improve our services based on usage patterns</li>
<li>
Send optional email notifications about new features and updates (you can opt out in
settings)
</li>
<li>Respond to support requests</li>
<li>Ensure platform security and prevent abuse</li>
</ul>
</section>
<section className="mb-8 space-y-4">
<h2 className="font-semibold text-xl">4. Data Storage</h2>
<p className="text-foreground/90 leading-relaxed">
Your data is stored using Supabase (PostgreSQL database) on secure cloud
infrastructure. We implement appropriate technical and organizational measures to
protect your data.
</p>
</section>
<section className="mb-8 space-y-4">
<h2 className="font-semibold text-xl">5. Third-Party Services</h2>
<p className="text-foreground/90 leading-relaxed">
We use the following third-party services to operate the Platform:
</p>
<ul className="list-disc space-y-2 pl-6 text-foreground/90">
<li>
<strong>Google</strong> - OAuth authentication for sign-in
</li>
<li>
<strong>Vercel</strong> - Application hosting, analytics, and performance monitoring
</li>
<li>
<strong>Supabase</strong> - Database hosting and authentication infrastructure
</li>
</ul>
<p className="mt-4 text-foreground/90 leading-relaxed">
Each of these services has their own privacy policies governing their handling of your
data.
</p>
</section>
<section className="mb-8 space-y-4">
<h2 className="font-semibold text-xl">6. Cookies</h2>
<p className="text-foreground/90 leading-relaxed">
We use minimal cookies necessary for the Platform to function:
</p>
<ul className="list-disc space-y-2 pl-6 text-foreground/90">
<li>
<strong>Session cookies</strong> - Essential for authentication and keeping you
signed in
</li>
<li>
<strong>Analytics cookies</strong> - Used by Vercel Analytics to collect anonymized
usage data
</li>
</ul>
</section>
<section className="mb-8 space-y-4">
<h2 className="font-semibold text-xl">7. Your Rights</h2>
<p className="text-foreground/90 leading-relaxed">You have the right to:</p>
<ul className="list-disc space-y-2 pl-6 text-foreground/90">
<li>Access the personal data we hold about you</li>
<li>Request correction of inaccurate data</li>
<li>Request deletion of your data</li>
<li>Export your project data</li>
<li>Opt out of marketing communications</li>
</ul>
<p className="mt-4 text-foreground/90 leading-relaxed">
To exercise any of these rights, please contact us at{' '}
<a
className="text-foreground underline hover:text-foreground/80"
href="mailto:support@pascal.app"
>
support@pascal.app
</a>
.
</p>
</section>
<section className="mb-8 space-y-4">
<h2 className="font-semibold text-xl">8. Data Retention</h2>
<p className="text-foreground/90 leading-relaxed">
We retain your data for as long as your account is active. If you delete your account,
we will delete your personal data and project data within 30 days, except where we are
required by law to retain certain information.
</p>
</section>
<section className="mb-8 space-y-4">
<h2 className="font-semibold text-xl">9. Children&apos;s Privacy</h2>
<p className="text-foreground/90 leading-relaxed">
The Platform is not intended for children under 13. We do not knowingly collect
personal information from children under 13. If you believe we have collected such
information, please contact us immediately.
</p>
</section>
<section className="mb-8 space-y-4">
<h2 className="font-semibold text-xl">10. Changes to This Policy</h2>
<p className="text-foreground/90 leading-relaxed">
We may update this Privacy Policy from time to time. We will notify you of material
changes by posting the updated policy on the Platform. Your continued use of the
Platform after changes are posted constitutes your acceptance of the revised policy.
</p>
</section>
<section className="space-y-4">
<h2 className="font-semibold text-xl">11. Contact Us</h2>
<p className="text-foreground/90 leading-relaxed">
If you have questions about this Privacy Policy or how we handle your data, please
contact us at{' '}
<a
className="text-foreground underline hover:text-foreground/80"
href="mailto:support@pascal.app"
>
support@pascal.app
</a>
.
</p>
</section>
</article>
</main>
</div>
)
}

View File

@ -0,0 +1,57 @@
import type { SceneGraph } from '@pascal-app/editor'
import { headers } from 'next/headers'
import Link from 'next/link'
import { SceneLoader, type SceneMeta } from '@/components/scene-loader'
import { getPascalHostIdentity } from '@/lib/pascal-host-identity'
import { getSceneOperations } from '@/lib/scene-store-server'
export const dynamic = 'force-dynamic'
interface SceneWithGraph extends SceneMeta {
graph: SceneGraph
}
async function fetchScene(id: string): Promise<SceneWithGraph | null> {
const identity = getPascalHostIdentity(await headers())
if (!identity.allowed) return null
const operations = await getSceneOperations()
const scene = await operations.loadStoredScene(id)
if (!scene || (identity.userId && scene.ownerId !== identity.userId)) return null
return scene as SceneWithGraph
}
export default async function ScenePage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
const scene = await fetchScene(id)
if (!scene) {
return (
<div className="flex min-h-screen items-center justify-center bg-background p-6">
<div className="w-full max-w-md rounded-2xl border border-border/60 bg-background p-6 text-center shadow-xl">
<p className="font-mono text-muted-foreground text-xs uppercase tracking-wide">404</p>
<h1 className="mt-2 font-semibold text-lg">未找到场景</h1>
<p className="mt-2 text-muted-foreground text-sm">
无法找到 ID 为 <code className="font-mono">{id}</code> 的场景。
</p>
<div className="mt-4 flex items-center justify-center gap-2">
<Link
className="rounded-md border border-border bg-accent px-3 py-2 font-medium text-sm hover:bg-accent/80"
href="/scenes"
>
查看场景
</Link>
<Link
className="rounded-md border border-border bg-background px-3 py-2 font-medium text-sm hover:bg-accent/40"
href="/"
>
返回编辑器
</Link>
</div>
</div>
</div>
)
}
const { graph, ...meta } = scene
return <SceneLoader initialScene={graph} meta={meta} />
}

View File

@ -0,0 +1,96 @@
import { headers } from 'next/headers'
import Link from 'next/link'
import { CreateSceneButton } from '@/components/save-button'
import type { SceneMeta } from '@/components/scene-loader'
import { getPascalHostIdentity } from '@/lib/pascal-host-identity'
import { getSceneOperations } from '@/lib/scene-store-server'
export const dynamic = 'force-dynamic'
async function fetchScenes(): Promise<SceneMeta[]> {
const identity = getPascalHostIdentity(await headers())
if (!identity.allowed) return []
const operations = await getSceneOperations()
return operations.listScenes({ limit: 50, ownerId: identity.userId ?? undefined })
}
function formatDate(iso: string): string {
try {
return new Date(iso).toLocaleString('zh-CN', { hour12: false })
} catch {
return iso
}
}
export default async function ScenesPage() {
const scenes = await fetchScenes()
return (
<div className="min-h-screen bg-background">
<header className="sticky top-0 z-10 border-border border-b bg-background/95 backdrop-blur">
<div className="container mx-auto flex items-center justify-between gap-4 px-6 py-4">
<nav className="flex items-center gap-4 text-sm">
<Link
className="text-muted-foreground transition-colors hover:text-foreground"
href="/"
>
首页
</Link>
<span className="text-muted-foreground">/</span>
<span className="font-medium text-foreground">场景</span>
</nav>
<CreateSceneButton />
</div>
</header>
<main className="container mx-auto max-w-5xl px-6 py-12">
<h1 className="mb-2 font-bold text-3xl">我的场景</h1>
<p className="mb-8 text-muted-foreground text-sm">
{scenes.length === 0 ? '还没有场景,创建一个开始设计吧。' : `共 ${scenes.length} 个场景`}
</p>
{scenes.length === 0 ? (
<div className="rounded-xl border border-border/60 border-dashed bg-background p-12 text-center">
<p className="text-muted-foreground text-sm">尚未保存任何场景。</p>
<div className="mt-4 flex justify-center">
<CreateSceneButton />
</div>
</div>
) : (
<ul className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{scenes.map((scene) => (
<li key={scene.id}>
<Link
className="group block rounded-xl border border-border/60 bg-background p-4 transition-colors hover:border-border hover:bg-accent/30"
href={`/scene/${scene.id}`}
>
<div className="flex aspect-video items-center justify-center overflow-hidden rounded-lg bg-accent/30">
{scene.thumbnailUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
alt={scene.name}
className="h-full w-full object-cover"
src={scene.thumbnailUrl}
/>
) : (
<span className="text-muted-foreground text-xs">暂无缩略图</span>
)}
</div>
<div className="mt-3">
<h2 className="truncate font-semibold text-sm group-hover:text-foreground">
{scene.name}
</h2>
<div className="mt-1 flex items-center justify-between text-muted-foreground text-xs">
<span>{scene.nodeCount} 个元素</span>
<time dateTime={scene.updatedAt}>{formatDate(scene.updatedAt)}</time>
</div>
</div>
</Link>
</li>
))}
</ul>
)}
</main>
</div>
)
}

View File

@ -0,0 +1,180 @@
import type { Metadata } from 'next'
import Link from 'next/link'
export const metadata: Metadata = {
title: 'Terms of Service',
description: 'Terms of Service for Pascal Editor and the Pascal platform.',
}
export default function TermsPage() {
return (
<div className="min-h-screen bg-background">
<header className="sticky top-0 z-10 border-border border-b bg-background/95 backdrop-blur">
<div className="container mx-auto px-6 py-4">
<nav className="flex items-center gap-4 text-sm">
<Link
className="text-muted-foreground transition-colors hover:text-foreground"
href="/"
>
Home
</Link>
<span className="text-muted-foreground">/</span>
<span className="font-medium text-foreground">Terms of Service</span>
<span className="text-muted-foreground">|</span>
<Link
className="text-muted-foreground transition-colors hover:text-foreground"
href="/privacy"
>
Privacy Policy
</Link>
</nav>
</div>
</header>
<main className="container mx-auto max-w-3xl px-6 py-12">
<article className="prose prose-neutral dark:prose-invert max-w-none">
<h1 className="mb-2 font-bold text-3xl">Terms of Service</h1>
<p className="mb-8 text-muted-foreground text-sm">Effective Date: February 20, 2026</p>
<section className="mb-8 space-y-4">
<h2 className="font-semibold text-xl">1. Introduction</h2>
<p className="text-foreground/90 leading-relaxed">
Welcome to Pascal Editor (&quot;Editor&quot;) and the Pascal platform at pascal.app
(&quot;Platform&quot;), operated by Pascal Group Inc. (&quot;we,&quot; &quot;us,&quot;
or &quot;our&quot;). By accessing or using our services, you agree to these Terms of
Service.
</p>
</section>
<section className="mb-8 space-y-4">
<h2 className="font-semibold text-xl">2. The Editor and Platform</h2>
<p className="text-foreground/90 leading-relaxed">
The Pascal Editor is open-source software released under the MIT License. You may use,
copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Editor
software in accordance with the MIT License terms.
</p>
<p className="text-foreground/90 leading-relaxed">
The Pascal platform (pascal.app) and its associated services, including user accounts,
cloud storage, and project hosting, are proprietary services owned and operated by
Pascal Group Inc. These Terms govern your use of the Platform.
</p>
</section>
<section className="mb-8 space-y-4">
<h2 className="font-semibold text-xl">3. Accounts and Authentication</h2>
<p className="text-foreground/90 leading-relaxed">
To use certain features of the Platform, you must create an account. We use Google
OAuth and magic link email authentication through Supabase. You are responsible for
maintaining the security of your account credentials and for all activities that occur
under your account.
</p>
</section>
<section className="mb-8 space-y-4">
<h2 className="font-semibold text-xl">4. Acceptable Use</h2>
<p className="text-foreground/90 leading-relaxed">You agree not to:</p>
<ul className="list-disc space-y-2 pl-6 text-foreground/90">
<li>
Use the Platform for any unlawful purpose or in violation of any applicable laws
</li>
<li>
Upload, share, or distribute content that infringes intellectual property rights
</li>
<li>Attempt to gain unauthorized access to the Platform or its systems</li>
<li>Interfere with or disrupt the Platform&apos;s infrastructure</li>
<li>Upload malicious code, viruses, or harmful content</li>
<li>Harass, abuse, or harm other users</li>
<li>Use the Platform to send spam or unsolicited communications</li>
</ul>
</section>
<section className="mb-8 space-y-4">
<h2 className="font-semibold text-xl">5. Your Content and Intellectual Property</h2>
<p className="text-foreground/90 leading-relaxed">
You retain full ownership of all content, projects, and data you create or upload to
the Platform (&quot;Your Content&quot;). By using the Platform, you grant us a limited
license to store, display, and transmit Your Content solely to provide our services to
you.
</p>
<p className="text-foreground/90 leading-relaxed">
We do not claim any ownership rights over Your Content. You may export or delete Your
Content at any time.
</p>
</section>
<section className="mb-8 space-y-4">
<h2 className="font-semibold text-xl">6. Platform Ownership</h2>
<p className="text-foreground/90 leading-relaxed">
The Platform, including its design, features, and proprietary code, is owned by Pascal
Group Inc. and protected by intellectual property laws. While the Editor source code
is open-source under the MIT License, the Platform services, branding, and
infrastructure remain our proprietary property.
</p>
</section>
<section className="mb-8 space-y-4">
<h2 className="font-semibold text-xl">7. Account Termination</h2>
<p className="text-foreground/90 leading-relaxed">
We reserve the right to suspend or terminate your account if you violate these Terms
or engage in conduct that we determine is harmful to the Platform or other users. You
may also delete your account at any time by contacting us at{' '}
<a
className="text-foreground underline hover:text-foreground/80"
href="mailto:support@pascal.app"
>
support@pascal.app
</a>
.
</p>
</section>
<section className="mb-8 space-y-4">
<h2 className="font-semibold text-xl">8. Disclaimer of Warranties</h2>
<p className="text-foreground/90 leading-relaxed">
THE PLATFORM IS PROVIDED &quot;AS IS&quot; AND &quot;AS AVAILABLE&quot; WITHOUT
WARRANTIES OF ANY KIND, EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO
IMPLIED WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND
NON-INFRINGEMENT.
</p>
<p className="text-foreground/90 leading-relaxed">
We do not warrant that the Platform will be uninterrupted, error-free, or free of
harmful components.
</p>
</section>
<section className="mb-8 space-y-4">
<h2 className="font-semibold text-xl">9. Limitation of Liability</h2>
<p className="text-foreground/90 leading-relaxed">
TO THE MAXIMUM EXTENT PERMITTED BY LAW, PASCAL GROUP INC. SHALL NOT BE LIABLE FOR ANY
INDIRECT, INCIDENTAL, SPECIAL, CONSEQUENTIAL, OR PUNITIVE DAMAGES, INCLUDING LOSS OF
DATA, PROFITS, OR GOODWILL, ARISING FROM YOUR USE OF THE PLATFORM.
</p>
</section>
<section className="mb-8 space-y-4">
<h2 className="font-semibold text-xl">10. Changes to Terms</h2>
<p className="text-foreground/90 leading-relaxed">
We may update these Terms from time to time. We will notify you of material changes by
posting the updated Terms on the Platform. Your continued use of the Platform after
changes are posted constitutes your acceptance of the revised Terms.
</p>
</section>
<section className="space-y-4">
<h2 className="font-semibold text-xl">11. Contact Us</h2>
<p className="text-foreground/90 leading-relaxed">
If you have questions about these Terms, please contact us at{' '}
<a
className="text-foreground underline hover:text-foreground/80"
href="mailto:support@pascal.app"
>
support@pascal.app
</a>
.
</p>
</section>
</article>
</main>
</div>
)
}

View File

@ -0,0 +1,348 @@
'use client'
import { useChat } from '@ai-sdk/react'
import { DefaultChatTransport, type UIMessage } from 'ai'
import {
ArrowUp,
Bot,
Check,
CircleAlert,
LoaderCircle,
Square,
Trash2,
Wrench,
} from 'lucide-react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { withPascalBasePath } from '@/lib/pascal-base-path'
interface AiDesignAgentPanelProps {
sceneId: string
}
interface AgentStatus {
configured: boolean
maxSteps: number
model: string
}
type DisplayPart = {
errorText?: string
state?: string
text?: string
toolName?: string
type: string
}
const STARTER_PROMPTS = [
'设计一个紧凑的两室一厅住宅',
'检查当前模型并修复结构问题',
'增加二层,并创建连接楼梯',
]
const TOOL_LABELS: Record<string, string> = {
add_door: '添加门',
add_window: '添加窗户',
apply_patch: '更新场景结构',
check_collisions: '检查空间碰撞',
create_house_from_brief: '生成建筑方案',
create_level: '创建楼层',
create_roof: '创建屋顶',
create_room: '创建房间',
create_stair_between_levels: '创建楼梯',
create_story_shell: '创建楼层结构',
create_wall: '创建墙体',
delete_node: '删除场景元素',
find_nodes: '查找场景元素',
furnish_room: '布置房间',
get_scene: '读取当前场景',
place_item: '放置物品',
save_scene: '保存设计进度',
validate_scene: '验证建筑结构',
}
function textOnlyHistory(messages: UIMessage[]): UIMessage[] {
return messages
.map((message) => ({
id: message.id,
parts: message.parts.filter((part) => part.type === 'text'),
role: message.role,
}))
.filter((message) => message.parts.length > 0)
.slice(-40) as UIMessage[]
}
function toolNameFromPart(part: DisplayPart): string | null {
if (part.toolName) return part.toolName
if (part.type.startsWith('tool-')) return part.type.slice(5)
return null
}
function ToolActivity({ part }: { part: DisplayPart }) {
const name = toolNameFromPart(part)
if (!name) return null
const isComplete = part.state === 'output-available'
const isError = part.state === 'output-error'
return (
<div className="flex items-center gap-2 border-border/60 border-y px-1 py-2 text-xs">
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground">
{isError ? (
<CircleAlert className="h-3.5 w-3.5 text-destructive" />
) : isComplete ? (
<Check className="h-3.5 w-3.5" />
) : (
<LoaderCircle className="h-3.5 w-3.5 animate-spin" />
)}
</span>
<div className="min-w-0 flex-1">
<div className="truncate font-medium text-foreground">
{TOOL_LABELS[name] ?? name.replaceAll('_', ' ')}
</div>
<div className={isError ? 'truncate text-destructive' : 'text-muted-foreground'}>
{isError ? part.errorText || '执行失败' : isComplete ? '已完成' : '正在执行'}
</div>
</div>
</div>
)
}
export function AiDesignAgentPanel({ sceneId }: AiDesignAgentPanelProps) {
const historyKey = `pascal-agent-history:${sceneId}`
const [input, setInput] = useState('')
const [agentStatus, setAgentStatus] = useState<AgentStatus | null>(null)
const [historyReady, setHistoryReady] = useState(false)
const endRef = useRef<HTMLDivElement>(null)
const transport = useMemo(
() =>
new DefaultChatTransport({
api: withPascalBasePath('/api/agent/chat'),
prepareSendMessagesRequest(request) {
return {
body: {
messages: request.messages,
sceneId,
},
}
},
}),
[sceneId],
)
const { error, messages, sendMessage, setMessages, status, stop } = useChat({
id: `pascal-agent:${sceneId}`,
experimental_throttle: 80,
transport,
})
const isRunning = status === 'submitted' || status === 'streaming'
const latestMessageRenderKey = useMemo(() => {
const latest = messages.at(-1)
if (!latest) return ''
return latest.parts
.map((part) => {
const displayPart = part as DisplayPart
return `${displayPart.type}:${displayPart.state ?? ''}:${displayPart.text?.length ?? 0}`
})
.join('|')
}, [messages])
useEffect(() => {
let active = true
fetch(withPascalBasePath('/api/agent/chat'))
.then(async (response) => {
if (!response.ok) throw new Error('status_unavailable')
return (await response.json()) as AgentStatus
})
.then((next) => {
if (active) setAgentStatus(next)
})
.catch(() => {
if (active) setAgentStatus({ configured: false, maxSteps: 0, model: '' })
})
return () => {
active = false
}
}, [])
useEffect(() => {
try {
const saved = window.localStorage.getItem(historyKey)
if (saved) {
const parsed = JSON.parse(saved) as UIMessage[]
if (Array.isArray(parsed)) setMessages(parsed)
}
} catch {
window.localStorage.removeItem(historyKey)
} finally {
setHistoryReady(true)
}
}, [historyKey, setMessages])
useEffect(() => {
if (!historyReady) return
window.localStorage.setItem(historyKey, JSON.stringify(textOnlyHistory(messages)))
}, [historyKey, historyReady, messages])
useEffect(() => {
if (!latestMessageRenderKey) return
endRef.current?.scrollIntoView({ behavior: isRunning ? 'smooth' : 'auto' })
}, [isRunning, latestMessageRenderKey])
const submit = () => {
const text = input.trim()
if (!text || isRunning || agentStatus?.configured === false) return
sendMessage({ role: 'user', parts: [{ type: 'text', text }] })
setInput('')
}
return (
<div className="flex h-full min-h-0 flex-col bg-background">
<div className="flex h-12 shrink-0 items-center justify-between border-border/70 border-b px-3">
<div className="flex min-w-0 items-center gap-2">
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-primary text-primary-foreground">
<Bot className="h-4 w-4" />
</span>
<div className="min-w-0">
<div className="truncate font-semibold text-sm">AI 设计助手</div>
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground">
<span
className={`h-1.5 w-1.5 rounded-full ${
agentStatus?.configured ? 'bg-emerald-500' : 'bg-amber-500'
}`}
/>
<span className="truncate">
{agentStatus === null
? '正在检查服务'
: agentStatus.configured
? agentStatus.model
: '尚未配置模型'}
</span>
</div>
</div>
</div>
<button
aria-label="清空对话"
className="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:opacity-40"
disabled={isRunning || messages.length === 0}
onClick={() => {
setMessages([])
window.localStorage.removeItem(historyKey)
}}
title="清空对话"
type="button"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-3 py-3">
{messages.length === 0 ? (
<div className="flex min-h-full flex-col justify-center py-6">
<div className="mb-4 flex h-10 w-10 items-center justify-center rounded-xl bg-muted text-foreground">
<Wrench className="h-5 w-5" />
</div>
<h2 className="font-semibold text-base">从描述开始建模</h2>
<p className="mt-1 max-w-[28rem] text-muted-foreground text-xs leading-5">
描述空间、面积、房间和风格要求,助手会直接操作当前场景并自动检查结果。
</p>
<div className="mt-5 flex flex-col gap-1.5">
{STARTER_PROMPTS.map((prompt) => (
<button
className="w-full rounded-md border border-border/70 px-3 py-2 text-left text-xs transition-colors hover:bg-muted"
key={prompt}
onClick={() => setInput(prompt)}
type="button"
>
{prompt}
</button>
))}
</div>
</div>
) : (
<div className="space-y-4">
{messages.map((message) => (
<div
className={message.role === 'user' ? 'flex justify-end' : 'flex justify-start'}
key={message.id}
>
<div
className={
message.role === 'user'
? 'max-w-[88%] rounded-lg bg-primary px-3 py-2 text-primary-foreground text-sm'
: 'w-full min-w-0 space-y-2 text-sm'
}
>
{message.parts.map((rawPart, index) => {
const part = rawPart as DisplayPart
if (part.type === 'text' && part.text) {
return (
<p className="whitespace-pre-wrap leading-6" key={`${message.id}-${index}`}>
{part.text}
</p>
)
}
return <ToolActivity key={`${message.id}-${index}`} part={part} />
})}
</div>
</div>
))}
{status === 'submitted' ? (
<div className="flex items-center gap-2 text-muted-foreground text-xs">
<LoaderCircle className="h-3.5 w-3.5 animate-spin" />
正在分析当前场景
</div>
) : null}
<div ref={endRef} />
</div>
)}
</div>
<div className="shrink-0 border-border/70 border-t p-3">
{agentStatus?.configured === false ? (
<div className="mb-2 flex items-start gap-2 rounded-md bg-amber-500/10 px-2.5 py-2 text-amber-700 text-xs dark:text-amber-300">
<CircleAlert className="mt-0.5 h-3.5 w-3.5 shrink-0" />
请先配置 PASCAL_AGENT_API_KEY、模型和接口地址。
</div>
) : null}
{error ? <div className="mb-2 text-destructive text-xs">{error.message}</div> : null}
<div className="flex items-end gap-2 rounded-lg border border-input bg-background p-2 shadow-sm focus-within:ring-1 focus-within:ring-ring">
<textarea
aria-label="输入建模要求"
className="max-h-32 min-h-9 min-w-0 flex-1 resize-none bg-transparent px-1 py-2 text-sm outline-none placeholder:text-muted-foreground"
disabled={agentStatus?.configured === false}
onChange={(event) => setInput(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault()
submit()
}
}}
placeholder="描述你想创建或修改的空间"
rows={1}
value={input}
/>
{isRunning ? (
<button
aria-label="停止建模"
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-primary text-primary-foreground"
onClick={() => stop()}
title="停止建模"
type="button"
>
<Square className="h-3.5 w-3.5 fill-current" />
</button>
) : (
<button
aria-label="发送"
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-primary text-primary-foreground transition-opacity disabled:opacity-35"
disabled={!input.trim() || agentStatus?.configured === false}
onClick={submit}
title="发送"
type="button"
>
<ArrowUp className="h-4 w-4" />
</button>
)}
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,578 @@
'use client'
import { nodeRegistry } from '@pascal-app/core'
import { MaterialPaintPanel, triggerSFX, useEditor } from '@pascal-app/editor'
import { useLiquidLineToolOptions } from '@pascal-app/nodes'
import Image from 'next/image'
import { useCallback, useEffect, useMemo, useRef } from 'react'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/toolbar-tooltip'
import { withPascalBasePath } from '@/lib/pascal-base-path'
import { cn } from '@/lib/utils'
/**
* Raw structure-tool kinds the Build tab can activate. These map 1:1 to the
* editor's `StructureTool` ids.
*/
type BuildToolKind =
| 'wall'
| 'fence'
| 'slab'
| 'ceiling'
| 'roof'
| 'stair'
| 'elevator'
| 'door'
| 'window'
| 'column'
| 'shelf'
| 'spawn'
/**
* MEP (mechanical / plumbing) tool kinds surfaced under the Build tab's "MEP"
* group tile — its own sub-grid, like Roof's "Features".
*/
type MepToolKind =
| 'duct-segment'
| 'duct-fitting'
| 'duct-terminal'
| 'hvac-equipment'
| 'lineset'
| 'liquid-line'
| 'pipe-segment'
| 'pipe-fitting'
| 'pipe-trap'
type BuildType = {
/** Selection id — equals `kind` for tool types, `'painting'` for paint mode, `'mep'` for the MEP group. */
id: string
label: string
/** Raster asset tile (legacy Build sidebar artwork). */
iconSrc: string
/** Present for structure-tool types (absent for paint mode and the MEP group). */
kind?: BuildToolKind
/** Non-placement special mode. */
mode?: 'material-paint'
}
type MepItem = {
/** Selection id — equals `kind`. */
id: string
label: string
iconSrc: string
kind: MepToolKind
}
// Same icons + ordering as the community Build sidebar, minus presets.
const BUILD_TYPES: BuildType[] = [
{ id: 'wall', label: '墙体', iconSrc: withPascalBasePath('/icons/wall.webp'), kind: 'wall' },
{ id: 'fence', label: '围栏', iconSrc: withPascalBasePath('/icons/fence.webp'), kind: 'fence' },
{ id: 'slab', label: '楼板', iconSrc: withPascalBasePath('/icons/floor.webp'), kind: 'slab' },
{
id: 'ceiling',
label: '天花板',
iconSrc: withPascalBasePath('/icons/ceiling.webp'),
kind: 'ceiling',
},
{ id: 'roof', label: '屋顶', iconSrc: withPascalBasePath('/icons/roof.webp'), kind: 'roof' },
{
id: 'stair',
label: '楼梯',
iconSrc: withPascalBasePath('/icons/stairs.webp'),
kind: 'stair',
},
{
id: 'elevator',
label: '电梯',
iconSrc: withPascalBasePath('/icons/elevator.webp'),
kind: 'elevator',
},
{ id: 'door', label: '门', iconSrc: withPascalBasePath('/icons/door.webp'), kind: 'door' },
{
id: 'window',
label: '窗户',
iconSrc: withPascalBasePath('/icons/window.webp'),
kind: 'window',
},
{
id: 'column',
label: '立柱',
iconSrc: withPascalBasePath('/icons/column.webp'),
kind: 'column',
},
{ id: 'shelf', label: '置物架', iconSrc: withPascalBasePath('/icons/shelf.webp'), kind: 'shelf' },
{
id: 'spawn',
label: '出生点',
iconSrc: withPascalBasePath('/icons/spawn-point.webp'),
kind: 'spawn',
},
// Group tile — no tool of its own; opens the MEP sub-grid below (like Roof).
{ id: 'mep', label: '机电管线', iconSrc: withPascalBasePath('/icons/HVAC.webp') },
{
id: 'painting',
label: '材质涂装',
iconSrc: withPascalBasePath('/icons/paint.webp'),
mode: 'material-paint',
},
]
// MEP sub-grid surfaced under the "MEP" tile — same icons + ordering the MEP
// tools had in the community Build sidebar.
const MEP_ITEMS: MepItem[] = [
{
id: 'duct-segment',
label: '风管',
iconSrc: withPascalBasePath('/icons/duct.webp'),
kind: 'duct-segment',
},
{
id: 'duct-terminal',
label: '风口',
iconSrc: withPascalBasePath('/icons/registers.webp'),
kind: 'duct-terminal',
},
{
id: 'hvac-equipment',
label: '暖通设备',
iconSrc: withPascalBasePath('/icons/HVAC.webp'),
kind: 'hvac-equipment',
},
{
id: 'lineset',
label: '冷媒管组',
iconSrc: withPascalBasePath('/icons/lineset.webp'),
kind: 'lineset',
},
{
id: 'liquid-line',
label: '液管',
iconSrc: withPascalBasePath('/icons/lineset.webp'),
kind: 'liquid-line',
},
{
id: 'pipe-segment',
label: '排水通气管',
iconSrc: withPascalBasePath('/icons/dwv-pipes.webp'),
kind: 'pipe-segment',
},
]
/**
* Activate a raw structure draw/cursor tool. Mirrors the editor's own
* structure-tool activation (`setPhase`/`setStructureLayer`/`setMode`/`setTool`).
*/
function activateBuildTool(kind: BuildToolKind | MepToolKind): void {
const ed = useEditor.getState()
ed.setPhase('structure')
ed.setStructureLayer('elements')
ed.setCatalogCategory(null)
ed.setToolDefaults(kind, null)
ed.setMode('build')
ed.setTool(kind)
}
/** Enter material-paint mode — the Build tab's "Painting" category. */
function activatePaintMode(): void {
const ed = useEditor.getState()
ed.setPhase('structure')
ed.setStructureLayer('elements')
ed.setMode('material-paint')
}
type RoofFeature = { kind: string; label: string; iconSrc: string }
const ROOF_FEATURE_LABELS: Record<string, string> = {
'box-vent': '箱式通风口',
chimney: '烟囱',
cupola: '屋顶钟楼',
dormer: '老虎窗',
downspout: '落水管',
'eyebrow-vent': '眉形通风口',
gutter: '天沟',
'ridge-vent': '屋脊通风口',
skylight: '天窗',
'solar-panel': '太阳能板',
'turbine-vent': '涡轮通风器',
}
const ROOF_FEATURE_FALLBACK_ICON = withPascalBasePath('/icons/roof.webp')
/**
* Roof accessories surfaced under the Roof tile (a "Features" group). Unlike
* the community editor these aren't DB presets — each is a registry kind with
* `capabilities.roofAccessory`, enumerated from the registry at render time
* (it is populated by the app bootstrap — a module-scope const would race it)
* and activated like any structure tool (the kind's tool attaches it to the
* roof segment under the cursor). Label + icon come from the registry's
* `presentation`; non-url icons fall back to the roof icon.
*/
function activateRoofFeatureTool(kind: string): void {
const ed = useEditor.getState()
ed.setPhase('structure')
ed.setStructureLayer('elements')
ed.setCatalogCategory(null)
ed.setMode('build')
ed.setTool(kind as Parameters<typeof ed.setTool>[0])
}
/**
* Build tab for the open-source standalone editor — a preset-less replica of
* the community Build sidebar. Clicking a type activates its raw tool, drawn
* with the kind's own `def.defaults()`. The "Painting" type swaps in the
* material-paint panel.
*/
// MEP tool kinds that, when active, mean the MEP group tile (and its sub-grid)
// is what the user is working in.
const MEP_TOOL_KINDS = new Set<string>([
...MEP_ITEMS.map((item) => item.kind),
'duct-fitting',
'pipe-fitting',
'pipe-trap',
])
export function BuildTab() {
const activeTool = useEditor((s) => s.tool)
const mode = useEditor((s) => s.mode)
const follow = useLiquidLineToolOptions((s) => s.follow)
const toggleFollow = useLiquidLineToolOptions((s) => s.toggleFollow)
// The fitting / follow tools are armed from a segment's panel, not a grid
// tile — keep the segment tile lit so the panel (and the way back) stays
// visible.
const ductContext =
mode === 'build' && (activeTool === 'duct-segment' || activeTool === 'duct-fitting')
const pipeContext =
mode === 'build' &&
(activeTool === 'pipe-segment' || activeTool === 'pipe-fitting' || activeTool === 'pipe-trap')
const liquidLineContext = mode === 'build' && activeTool === 'liquid-line'
const isMepItemActive = (item: MepItem) =>
item.kind === 'duct-segment'
? ductContext
: item.kind === 'pipe-segment'
? pipeContext
: item.kind === 'liquid-line'
? liquidLineContext
: mode === 'build' && activeTool === item.kind
// Read at render time (not module scope): the registry is populated by the
// app bootstrap, so enumerating earlier would race it and see no kinds.
const roofFeatures = useMemo<RoofFeature[]>(() => {
const features: RoofFeature[] = []
for (const [kind, def] of nodeRegistry.entries()) {
if (def.capabilities.roofAccessory === undefined) continue
// Door / window declare `roofAccessory` for the wall-face cut but
// already have their own Build tiles — listing them here too
// would duplicate the entry under Roof → Features.
if (def.capabilities.wallOpeningPlacement) continue
const icon = def.presentation?.icon
features.push({
kind,
label: ROOF_FEATURE_LABELS[kind] ?? def.presentation?.label ?? kind,
iconSrc: icon?.kind === 'url' ? icon.src : ROOF_FEATURE_FALLBACK_ICON,
})
}
return features
}, [])
// Tile highlight derives from the single source of truth (the active tool /
// mode), never a separate local selection — so keyboard shortcuts and panel
// clicks always agree on which tile is lit.
// The roof Features sub-grid arms roof-accessory tools (skylight, chimney,
// …); keep the Roof tile lit (and its panel open) while any of them is the
// active tool, the same way MEP stays lit for its sub-grid tools.
const isRoofFeatureActive =
mode === 'build' && !!activeTool && roofFeatures.some((f) => f.kind === activeTool)
const isMepActive = mode === 'build' && !!activeTool && MEP_TOOL_KINDS.has(activeTool)
const isTypeActive = (type: BuildType) => {
if (type.mode === 'material-paint') return mode === 'material-paint'
if (type.id === 'mep') return isMepActive
if (type.id === 'roof')
return mode === 'build' && (activeTool === 'roof' || isRoofFeatureActive)
return mode === 'build' && activeTool === type.kind
}
const handleTypeClick = useCallback((type: BuildType) => {
if (type.mode === 'material-paint') {
activatePaintMode()
} else if (type.id === 'mep') {
// MEP is a group tile: arm its first tool so a usable tool is active
// (and we leave any prior paint mode), then reveal the MEP sub-grid.
activateBuildTool('duct-segment')
} else if (type.kind) {
activateBuildTool(type.kind)
}
}, [])
// On open, land on the first build tool — parity with the community Build
// sidebar, so switching to Build immediately arms a usable tool. Skip when a
// build tool is already active (e.g. the B shortcut armed one before this
// panel mounted): the active tool is the source of truth, not this default.
const didInitRef = useRef(false)
useEffect(() => {
if (didInitRef.current) return
didInitRef.current = true
const ed = useEditor.getState()
if (ed.mode === 'build' && ed.tool) return
const firstType = BUILD_TYPES.find((t) => t.kind)
if (firstType) handleTypeClick(firstType)
}, [handleTypeClick])
return (
<div className="flex h-full flex-col gap-3 p-3">
<TooltipProvider delayDuration={0} disableHoverableContent>
<div
className="grid gap-1.5"
style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(56px, 1fr))' }}
>
{BUILD_TYPES.map((type) => {
const active = isTypeActive(type)
return (
<Tooltip key={type.id}>
<TooltipTrigger asChild>
<button
className={cn(
'group relative flex aspect-square items-center justify-center rounded-xl p-1 transition-all duration-200',
active
? 'bg-primary/10 ring-1 ring-primary/50'
: 'bg-muted/40 opacity-70 grayscale hover:bg-muted hover:opacity-100 hover:grayscale-0',
)}
onClick={() => {
triggerSFX('sfx:menu-click')
handleTypeClick(type)
}}
onMouseEnter={() => triggerSFX('sfx:menu-hover')}
type="button"
>
<Image
alt={type.label}
className="size-full object-contain transition-transform duration-200 group-hover:scale-110"
height={48}
src={type.iconSrc}
width={48}
/>
</button>
</TooltipTrigger>
<TooltipContent className="pointer-events-none" side="top">
{type.label}
</TooltipContent>
</Tooltip>
)
})}
</div>
</TooltipProvider>
{mode === 'material-paint' ? (
<div className="min-h-0 flex-1 overflow-y-auto">
<MaterialPaintPanel />
</div>
) : mode === 'build' &&
(activeTool === 'roof' || isRoofFeatureActive) &&
roofFeatures.length > 0 ? (
<div className="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto">
<div className="px-0.5 pt-1 font-medium text-muted-foreground text-xs">屋顶构件</div>
<TooltipProvider delayDuration={0} disableHoverableContent>
<div
className="grid gap-1.5"
style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(56px, 1fr))' }}
>
{roofFeatures.map((feature) => {
const active = mode === 'build' && activeTool === feature.kind
return (
<Tooltip key={feature.kind}>
<TooltipTrigger asChild>
<button
className={cn(
'group relative flex aspect-square items-center justify-center rounded-xl p-1 transition-all duration-200',
active
? 'bg-primary/10 ring-1 ring-primary/50'
: 'bg-muted/40 opacity-70 grayscale hover:bg-muted hover:opacity-100 hover:grayscale-0',
)}
onClick={() => {
triggerSFX('sfx:menu-click')
activateRoofFeatureTool(feature.kind)
}}
onMouseEnter={() => triggerSFX('sfx:menu-hover')}
type="button"
>
<Image
alt={feature.label}
className="size-full object-contain transition-transform duration-200 group-hover:scale-110"
height={48}
src={feature.iconSrc}
width={48}
/>
</button>
</TooltipTrigger>
<TooltipContent className="pointer-events-none" side="top">
{feature.label}
</TooltipContent>
</Tooltip>
)
})}
</div>
</TooltipProvider>
</div>
) : isMepActive ? (
<div className="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto">
<div className="px-0.5 pt-1 font-medium text-muted-foreground text-xs">机电管线</div>
<TooltipProvider delayDuration={0} disableHoverableContent>
<div
className="grid gap-1.5 px-0.5"
style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(56px, 1fr))' }}
>
{MEP_ITEMS.map((item) => {
const active = isMepItemActive(item)
return (
<Tooltip key={item.id}>
<TooltipTrigger asChild>
<button
className={cn(
'group relative flex aspect-square items-center justify-center rounded-xl transition-all duration-200',
active
? 'bg-primary/10 ring-1 ring-primary/50'
: 'bg-muted/40 opacity-70 grayscale hover:bg-muted hover:opacity-100 hover:grayscale-0',
)}
onClick={() => {
triggerSFX('sfx:menu-click')
activateBuildTool(item.kind)
}}
onMouseEnter={() => triggerSFX('sfx:menu-hover')}
type="button"
>
<Image
alt={item.label}
className="size-full object-contain transition-transform duration-200 group-hover:scale-110"
height={48}
src={item.iconSrc}
width={48}
/>
</button>
</TooltipTrigger>
<TooltipContent className="pointer-events-none" side="top">
{item.label}
</TooltipContent>
</Tooltip>
)
})}
</div>
</TooltipProvider>
{ductContext ? (
<div className="flex flex-col gap-1.5">
<span className="text-muted-foreground text-xs">风管</span>
<button
className={cn(
'flex items-center gap-2 rounded-lg px-3 py-2 text-sm transition-all duration-200',
activeTool === 'duct-fitting'
? 'bg-primary/10 ring-1 ring-primary/50'
: 'bg-muted/40 hover:bg-muted',
)}
onClick={() => {
triggerSFX('sfx:menu-click')
activateBuildTool(activeTool === 'duct-fitting' ? 'duct-segment' : 'duct-fitting')
}}
onMouseEnter={() => triggerSFX('sfx:menu-hover')}
type="button"
>
<Image
alt=""
aria-hidden
className="size-4 object-contain"
height={16}
src={withPascalBasePath('/icons/duct-fitting.webp')}
width={16}
/>
添加管件
</button>
</div>
) : null}
{pipeContext ? (
<div className="flex flex-col gap-1.5">
<span className="text-muted-foreground text-xs">排水通气管</span>
<button
className={cn(
'flex items-center gap-2 rounded-lg px-3 py-2 text-sm transition-all duration-200',
activeTool === 'pipe-fitting'
? 'bg-primary/10 ring-1 ring-primary/50'
: 'bg-muted/40 hover:bg-muted',
)}
onClick={() => {
triggerSFX('sfx:menu-click')
activateBuildTool(activeTool === 'pipe-fitting' ? 'pipe-segment' : 'pipe-fitting')
}}
onMouseEnter={() => triggerSFX('sfx:menu-hover')}
type="button"
>
<Image
alt=""
aria-hidden
className="size-4 object-contain"
height={16}
src={withPascalBasePath('/icons/duct-fitting.webp')}
width={16}
/>
添加管件
</button>
<button
className={cn(
'flex items-center gap-2 rounded-lg px-3 py-2 text-sm transition-all duration-200',
activeTool === 'pipe-trap'
? 'bg-primary/10 ring-1 ring-primary/50'
: 'bg-muted/40 hover:bg-muted',
)}
onClick={() => {
triggerSFX('sfx:menu-click')
activateBuildTool(activeTool === 'pipe-trap' ? 'pipe-segment' : 'pipe-trap')
}}
onMouseEnter={() => triggerSFX('sfx:menu-hover')}
type="button"
>
<Image
alt=""
aria-hidden
className="size-4 object-contain"
height={16}
src={withPascalBasePath('/icons/dwv-pipes.webp')}
width={16}
/>
添加存水弯
</button>
</div>
) : null}
{liquidLineContext ? (
<div className="flex flex-col gap-1.5">
<span className="text-muted-foreground text-xs">液管</span>
<button
className={cn(
'flex items-center justify-between gap-2 rounded-lg px-3 py-2 text-sm transition-all duration-200',
follow ? 'bg-primary/10 ring-1 ring-primary/50' : 'bg-muted/40 hover:bg-muted',
)}
onClick={() => {
triggerSFX('sfx:menu-click')
toggleFollow()
}}
onMouseEnter={() => triggerSFX('sfx:menu-hover')}
type="button"
>
<span>跟随冷媒管组</span>
<span className="text-muted-foreground text-xs">{follow ? '开' : '关'}</span>
</button>
<span className="px-1 text-[11px] text-muted-foreground">
{follow ? '单击冷媒管组,在其旁边铺设液管。' : '沿现有冷媒管组绘制液管(F)。'}
</span>
</div>
) : null}
</div>
) : null}
</div>
)
}

View File

@ -0,0 +1,158 @@
'use client'
import type { SceneGraph } from '@pascal-app/editor'
import { useRouter } from 'next/navigation'
import { useCallback, useState } from 'react'
import { withPascalBasePath } from '@/lib/pascal-base-path'
const EMPTY_GRAPH: SceneGraph = {
nodes: {},
rootNodeIds: [],
}
interface SaveButtonProps {
sceneId: string
name: string
version: number
getGraph: () => SceneGraph | null
}
/**
* Creates a new empty scene and navigates the user to it.
*/
export function CreateSceneButton({ label = '新建场景' }: { label?: string } = {}) {
const router = useRouter()
const [isCreating, setIsCreating] = useState(false)
const [error, setError] = useState<string | null>(null)
const handleCreate = useCallback(async () => {
setIsCreating(true)
setError(null)
try {
const response = await fetch(withPascalBasePath('/api/scenes'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: '未命名场景', graph: EMPTY_GRAPH }),
})
if (!response.ok) {
setError(`创建场景失败 (${response.status})`)
return
}
const meta = (await response.json()) as { id: string }
router.push(`/scene/${meta.id}`)
} catch (err) {
setError(err instanceof Error ? err.message : '创建场景失败')
} finally {
setIsCreating(false)
}
}, [router])
return (
<div className="flex items-center gap-3">
{error && <span className="text-destructive text-xs">{error}</span>}
<button
className="rounded-md border border-border bg-accent px-3 py-1.5 font-medium text-sm hover:bg-accent/80 disabled:opacity-50"
disabled={isCreating}
onClick={handleCreate}
type="button"
>
{isCreating ? '正在创建…' : label}
</button>
</div>
)
}
/**
* Save + Save-as buttons that call the scenes API directly.
* Used for UIs that want explicit save controls outside of the Editor's
* built-in autosave plumbing.
*/
export function SaveButton({ sceneId, name, version, getGraph }: SaveButtonProps) {
const router = useRouter()
const [isSaving, setIsSaving] = useState(false)
const [status, setStatus] = useState<string | null>(null)
const handleSave = useCallback(async () => {
const graph = getGraph()
if (!graph) {
setStatus('没有可保存的场景')
return
}
setIsSaving(true)
setStatus(null)
try {
const response = await fetch(withPascalBasePath(`/api/scenes/${sceneId}`), {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'If-Match': String(version),
},
body: JSON.stringify({ name, graph }),
})
if (response.status === 409) {
setStatus('保存冲突,请刷新后继续')
return
}
if (!response.ok) {
setStatus(`保存失败 (${response.status})`)
return
}
setStatus('已保存')
} catch (error) {
setStatus(error instanceof Error ? error.message : '保存失败')
} finally {
setIsSaving(false)
}
}, [getGraph, name, sceneId, version])
const handleSaveAs = useCallback(async () => {
const graph = getGraph()
if (!graph) {
setStatus('没有可保存的场景')
return
}
const newName = typeof window !== 'undefined' ? window.prompt('请输入新场景名称', name) : null
if (!newName) return
setIsSaving(true)
setStatus(null)
try {
const response = await fetch(withPascalBasePath('/api/scenes'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: newName, graph }),
})
if (!response.ok) {
setStatus(`另存为失败 (${response.status})`)
return
}
const meta = (await response.json()) as { id: string }
router.push(`/scene/${meta.id}`)
} catch (error) {
setStatus(error instanceof Error ? error.message : '另存为失败')
} finally {
setIsSaving(false)
}
}, [getGraph, name, router])
return (
<div className="flex items-center gap-2">
<button
className="rounded-md border border-border bg-accent px-3 py-1.5 font-medium text-xs hover:bg-accent/80 disabled:opacity-50"
disabled={isSaving}
onClick={handleSave}
type="button"
>
{isSaving ? '正在保存…' : '保存'}
</button>
<button
className="rounded-md border border-border bg-background px-3 py-1.5 font-medium text-xs hover:bg-accent/40 disabled:opacity-50"
disabled={isSaving}
onClick={handleSaveAs}
type="button"
>
另存为…
</button>
{status && <span className="text-muted-foreground text-xs">{status}</span>}
</div>
)
}

View File

@ -0,0 +1,264 @@
'use client'
// Node registry bootstrap is loaded once at the root via
// `<ClientBootstrap>` in `app/layout.tsx` — no per-page side-effect
// import here.
import {
applySceneGraphToEditor,
Editor,
type SceneGraph,
type SidebarTab,
} from '@pascal-app/editor'
import { Bot, Hammer, Layers } from 'lucide-react'
import Image from 'next/image'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { withPascalBasePath } from '@/lib/pascal-base-path'
import { AiDesignAgentPanel } from './ai-design-agent-panel'
import { BuildTab } from './build-tab'
import { CommunityViewerToolbarLeft, CommunityViewerToolbarRight } from './viewer-toolbar'
export interface SceneMeta {
id: string
name: string
projectId: string | null
thumbnailUrl: string | null
version: number
createdAt: string
updatedAt: string
ownerId: string | null
sizeBytes: number
nodeCount: number
}
const BASE_SIDEBAR_TABS: (SidebarTab & { component: React.ComponentType })[] = [
{
id: 'site',
label: '场景',
component: () => null, // Built-in SitePanel handles this
mobileDefaultSnap: 0.5,
mobileIcon: <Layers className="h-5 w-5" />,
icon: (
<Image
alt=""
className="h-8 w-8 object-contain"
height={32}
src={withPascalBasePath('/icons/scene.webp')}
width={32}
/>
),
},
{
id: 'build',
label: '搭建',
component: BuildTab,
mobileDefaultSnap: 0.5,
mobileIcon: <Hammer className="h-5 w-5" />,
icon: (
<Image
alt=""
className="h-8 w-8 object-contain"
height={32}
src={withPascalBasePath('/icons/build.webp')}
width={32}
/>
),
},
]
interface SceneLoaderProps {
initialScene: SceneGraph
meta: SceneMeta
}
type SceneGraphWithCollections = SceneGraph & {
collections?: Record<string, unknown>
}
interface LiveSceneEvent {
eventId: number
sceneId: string
version: number
kind: string
createdAt: string
graph: SceneGraphWithCollections
}
function sceneGraphSignature(graph: SceneGraphWithCollections): string {
return JSON.stringify({
nodes: graph.nodes,
rootNodeIds: graph.rootNodeIds,
collections: graph.collections,
})
}
export function SceneLoader({ initialScene, meta }: SceneLoaderProps) {
const router = useRouter()
const versionRef = useRef(meta.version)
const lastRemoteGraphJsonRef = useRef<string | null>(null)
const suppressRemoteSaveUntilRef = useRef(0)
const [conflict, setConflict] = useState(false)
const [saveError, setSaveError] = useState<string | null>(null)
const sidebarTabs = useMemo<(SidebarTab & { component: React.ComponentType })[]>(() => {
function SceneAgentPanel() {
return <AiDesignAgentPanel sceneId={meta.id} />
}
return [
...BASE_SIDEBAR_TABS,
{
id: 'ai',
label: 'AI 设计助手',
component: SceneAgentPanel,
mobileDefaultSnap: 0.72,
mobileIcon: <Bot className="h-5 w-5" />,
icon: <Bot className="h-6 w-6" />,
},
]
}, [meta.id])
const handleLoad = useCallback(async () => initialScene, [initialScene])
const handleSave = useCallback(
async (graph: SceneGraph, options?: { keepalive?: boolean }) => {
const graphJson = sceneGraphSignature(graph)
const isRecentRemoteApply = Date.now() < suppressRemoteSaveUntilRef.current
if (lastRemoteGraphJsonRef.current === graphJson) {
lastRemoteGraphJsonRef.current = null
suppressRemoteSaveUntilRef.current = 0
return
}
if (isRecentRemoteApply) return
try {
const response = await fetch(withPascalBasePath(`/api/scenes/${meta.id}`), {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'If-Match': String(versionRef.current),
},
body: JSON.stringify({ name: meta.name, graph }),
// `keepalive` lets the request outlive a page unload (the autosave
// flush on refresh/close). Browsers cap keepalive bodies at 64KB, so
// only the unload flush opts in — normal debounced saves omit it and
// can carry arbitrarily large scenes.
keepalive: options?.keepalive,
})
if (response.status === 409) {
setConflict(true)
return
}
if (!response.ok) {
setSaveError(`保存失败 (${response.status})`)
return
}
const next = (await response.json()) as SceneMeta
versionRef.current = next.version
setSaveError(null)
} catch (error) {
setSaveError(error instanceof Error ? error.message : '保存失败')
}
},
[meta.id, meta.name],
)
useEffect(() => {
const source = new EventSource(withPascalBasePath(`/api/scenes/${meta.id}/events`))
source.addEventListener('scene', (event) => {
let payload: LiveSceneEvent
try {
payload = JSON.parse((event as MessageEvent<string>).data) as LiveSceneEvent
} catch {
return
}
if (payload.sceneId !== meta.id) return
if (payload.version <= versionRef.current) return
versionRef.current = payload.version
lastRemoteGraphJsonRef.current = sceneGraphSignature(payload.graph)
suppressRemoteSaveUntilRef.current = Date.now() + 2500
applySceneGraphToEditor(payload.graph)
setConflict(false)
setSaveError(null)
})
source.addEventListener('error', () => {
if (source.readyState === EventSource.CLOSED) {
setSaveError('场景实时连接已断开')
}
})
return () => source.close()
}, [meta.id])
const handleThumb = useCallback(
async (_blob: Blob) => {
// TODO(phase7): upload thumbnail via POST /api/scenes/[id]/thumbnail.
// Stub endpoint is not yet implemented in v0.1 — skip upload for now.
await fetch(withPascalBasePath(`/api/scenes/${meta.id}/thumbnail`), {
method: 'POST',
// Intentionally no body — endpoint is a stub.
}).catch(() => {
// Swallow errors silently; thumbnail upload is best-effort.
})
},
[meta.id],
)
return (
<div className="relative h-screen w-screen">
{conflict && (
<div className="pointer-events-auto absolute top-4 left-1/2 z-50 w-full max-w-md -translate-x-1/2 rounded-lg border border-border bg-background p-4 shadow-xl">
<h2 className="font-semibold text-sm">其他会话已先保存,是否刷新?</h2>
<p className="mt-1 text-muted-foreground text-xs">
当前更改尚未保存,请重新加载最新版本。
</p>
<div className="mt-3 flex items-center gap-2">
<button
className="rounded-md border border-border bg-accent px-3 py-1.5 font-medium text-xs hover:bg-accent/80"
onClick={() => router.refresh()}
type="button"
>
重新加载
</button>
<button
className="rounded-md border border-border bg-background px-3 py-1.5 font-medium text-xs hover:bg-accent/40"
onClick={() => setConflict(false)}
type="button"
>
暂不处理
</button>
</div>
</div>
)}
{saveError && !conflict && (
<div className="pointer-events-auto absolute top-4 left-1/2 z-50 w-full max-w-md -translate-x-1/2 rounded-lg border border-destructive/50 bg-background p-3 shadow-xl">
<p className="font-medium text-destructive text-xs">{saveError}</p>
</div>
)}
<div className="pointer-events-none absolute top-4 right-4 z-40 flex items-center gap-2">
<Link
className="pointer-events-auto rounded-md border border-border bg-background/90 px-3 py-1.5 font-medium text-xs shadow-sm backdrop-blur hover:bg-accent/40"
href="/scenes"
>
全部场景
</Link>
</div>
<Editor
layoutVersion="v2"
onLoad={handleLoad}
onSave={handleSave}
onThumbnailCapture={handleThumb}
projectId={meta.projectId ?? 'default'}
sidebarTabs={sidebarTabs}
viewerToolbarLeft={<CommunityViewerToolbarLeft />}
viewerToolbarRight={<CommunityViewerToolbarRight />}
/>
</div>
)
}

View File

@ -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 }

View File

@ -0,0 +1,545 @@
'use client'
import { Icon as IconifyIcon } from '@iconify/react'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
useEditor,
useSidebarStore,
type ViewMode,
} from '@pascal-app/editor'
import {
CLAY_PALETTE,
type EdgeMode,
getSceneTheme,
SCENE_THEMES,
useViewer,
} from '@pascal-app/viewer'
import {
Box,
Check,
ChevronsLeft,
ChevronsRight,
Columns2,
Contrast,
Eye,
EyeOff,
Footprints,
Grid2X2,
Magnet,
PenLine,
SlidersHorizontal,
Sparkles,
SwatchBook,
} from 'lucide-react'
import Image from 'next/image'
import { type ReactNode, useCallback } from 'react'
import { flushSync } from 'react-dom'
import { withPascalBasePath } from '@/lib/pascal-base-path'
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 requestWalkthroughPointerLock() {
const canvas = document.querySelector<HTMLCanvasElement>('[data-pascal-viewer-3d] canvas')
if (!canvas) return
if (!canvas.hasAttribute('tabindex')) {
canvas.tabIndex = -1
}
canvas.focus({ preventScroll: true })
if (document.pointerLockElement === canvas) return
try {
canvas.requestPointerLock?.()
} catch {
return
}
}
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={withPascalBasePath('/icons/building.webp')}
width={14}
/>
),
},
{
id: '2d',
label: '2D',
icon: (
<Image
alt=""
className="h-3.5 w-3.5 object-contain"
height={14}
src={withPascalBasePath('/icons/blueprint.webp')}
width={14}
/>
),
},
{
id: 'split',
label: '分屏',
icon: <Columns2 className="h-3 w-3" />,
},
]
const levelModeOrder = ['stacked', 'exploded', 'solo'] as const
const levelModeLabels: Record<string, string> = {
manual: '堆叠',
stacked: '堆叠',
exploded: '分层',
solo: '单层',
}
const wallModeOrder = ['cutaway', 'up', 'down', 'translucent'] as const
const wallModeConfig: Record<string, { icon: string; label: string }> = {
up: { icon: withPascalBasePath('/icons/room.webp'), label: '全高' },
cutaway: { icon: withPascalBasePath('/icons/wallcut.webp'), label: '剖切' },
down: { icon: withPascalBasePath('/icons/walllow.webp'), label: '低墙' },
translucent: { icon: withPascalBasePath('/icons/wall.webp'), label: '半透明' },
}
const SHADING_OPTIONS = [
{ id: 'solid', name: '实体', detail: '快速平面显示,不含环境光遮蔽', icon: Box },
{ id: 'rendered', name: '渲染', detail: '完整环境光遮蔽效果', icon: Sparkles },
] as const
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 ? '展开侧边栏' : '收起侧边栏'}>
<button
aria-label={isCollapsed ? '展开侧边栏' : '收起侧边栏'}
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 = `楼层:${levelMode === 'manual' ? '手动' : (levelModeLabels[levelMode] ?? '堆叠')}`
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] ?? '堆叠'}</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={`墙体:${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>
)
}
// One dropdown that gathers every "how the scene looks" control: grid, shadows,
// camera projection, units, render mode, edges and scene theme.
const EDGE_OPTIONS = [
{ id: 'off', name: '关闭', detail: '不显示边线' },
{ id: 'soft', name: '柔和', detail: '淡化显示主要折线' },
{ id: 'strong', name: '清晰', detail: '显示清晰不透明边线' },
] as const satisfies readonly { id: EdgeMode; name: string; detail: string }[]
const SCENE_THEME_LABELS: Record<string, string> = {
studio: '工作室',
paper: '纸张',
sunset: '日落',
overcast: '阴天',
blueprint: '蓝图',
mediterranean: '地中海',
twilight: '黄昏',
night: '夜景',
verdant: '绿意',
}
function getSceneThemeLabel(id: string, fallback: string) {
return SCENE_THEME_LABELS[id] ?? fallback
}
const SUBMENU_CONTENT_CLASS = 'min-w-56 rounded-xl border-border/45 bg-popover/95 backdrop-blur-xl'
function DisplayMenu() {
const showGrid = useViewer((state) => state.showGrid)
const setShowGrid = useViewer((state) => state.setShowGrid)
const unit = useViewer((state) => state.unit)
const setUnit = useViewer((state) => state.setUnit)
const cameraMode = useViewer((state) => state.cameraMode)
const setCameraMode = useViewer((state) => state.setCameraMode)
const shading = useViewer((state) => state.shading)
const setShading = useViewer((state) => state.setShading)
const sceneTheme = useViewer((state) => state.sceneTheme)
const setSceneTheme = useViewer((state) => state.setSceneTheme)
const edges = useViewer((state) => state.edges)
const setEdges = useViewer((state) => state.setEdges)
const shadows = useViewer((state) => state.shadows)
const setShadows = useViewer((state) => state.setShadows)
const magneticSnap = useEditor((state) => state.magneticSnap)
const setMagneticSnap = useEditor((state) => state.setMagneticSnap)
const activeShading =
SHADING_OPTIONS.find((option) => option.id === shading) ?? SHADING_OPTIONS[0]
const activeEdges = EDGE_OPTIONS.find((option) => option.id === edges) ?? EDGE_OPTIONS[0]
const activeTheme = getSceneTheme(sceneTheme)
// Keep the menu open when flipping a toggle.
const keepOpen = (event: Event, fn: () => void) => {
event.preventDefault()
fn()
}
return (
<DropdownMenu>
<ToolbarTooltip label="显示设置">
<DropdownMenuTrigger asChild>
<button
aria-label="显示设置"
className={cn(TOOLBAR_BTN, 'w-auto gap-1.5 px-2.5 text-foreground/90')}
type="button"
>
<SlidersHorizontal className="h-3.5 w-3.5 shrink-0" />
<span className="font-medium text-xs">显示</span>
</button>
</DropdownMenuTrigger>
</ToolbarTooltip>
<DropdownMenuContent
align="end"
className="w-60 rounded-xl border-border/45 bg-popover/95 backdrop-blur-xl"
side="bottom"
sideOffset={8}
>
<DropdownMenuItem onSelect={(e) => keepOpen(e, () => setShowGrid(!showGrid))}>
<Grid2X2 className="h-4 w-4" />
<span>网格</span>
{showGrid ? (
<Eye className="ml-auto h-4 w-4 text-foreground" />
) : (
<EyeOff className="ml-auto h-4 w-4 text-muted-foreground" />
)}
</DropdownMenuItem>
<DropdownMenuItem onSelect={(e) => keepOpen(e, () => setMagneticSnap(!magneticSnap))}>
<Magnet className="h-4 w-4" />
<span>磁吸捕捉</span>
<span className="ml-auto text-muted-foreground text-xs">
{magneticSnap ? '开' : '关'}
</span>
</DropdownMenuItem>
<DropdownMenuItem onSelect={(e) => keepOpen(e, () => setShadows(!shadows))}>
<Contrast className="h-4 w-4" />
<span>阴影</span>
<span className="ml-auto text-muted-foreground text-xs">{shadows ? '开' : '关'}</span>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={(e) =>
keepOpen(e, () =>
setCameraMode(cameraMode === 'perspective' ? 'orthographic' : 'perspective'),
)
}
>
<IconifyIcon
height={16}
icon={cameraMode === 'perspective' ? 'icon-park-outline:perspective' : 'vaadin:grid'}
width={16}
/>
<span>相机</span>
<span className="ml-auto text-muted-foreground text-xs">
{cameraMode === 'perspective' ? '透视' : '正交'}
</span>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={(e) => keepOpen(e, () => setUnit(unit === 'metric' ? 'imperial' : 'metric'))}
>
<span className="flex h-4 w-4 items-center justify-center font-semibold text-[10px]">
{unit === 'metric' ? 'm' : 'ft'}
</span>
<span>单位</span>
<span className="ml-auto text-muted-foreground text-xs">
{unit === 'metric' ? '公制' : '英制'}
</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<activeShading.icon className="h-4 w-4" />
<span>渲染</span>
<span className="ml-auto text-muted-foreground text-xs">{activeShading.name}</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className={SUBMENU_CONTENT_CLASS}>
{SHADING_OPTIONS.map((option) => {
const OptionIcon = option.icon
return (
<DropdownMenuItem key={option.id} onSelect={() => setShading(option.id)}>
<OptionIcon className="h-4 w-4" />
<div className="flex flex-col">
<span className="text-foreground">{option.name}</span>
<span className="text-muted-foreground text-xs">{option.detail}</span>
</div>
{shading === option.id ? (
<Check className="ml-auto h-4 w-4 text-foreground" />
) : null}
</DropdownMenuItem>
)
})}
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<PenLine className="h-4 w-4" />
<span>边线</span>
<span className="ml-auto text-muted-foreground text-xs">{activeEdges.name}</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className={SUBMENU_CONTENT_CLASS}>
{EDGE_OPTIONS.map((option) => (
<DropdownMenuItem key={option.id} onSelect={() => setEdges(option.id)}>
<div className="flex flex-col">
<span className="text-foreground">{option.name}</span>
<span className="text-muted-foreground text-xs">{option.detail}</span>
</div>
{edges === option.id ? <Check className="ml-auto h-4 w-4 text-foreground" /> : null}
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<SwatchBook className="h-4 w-4" />
<span>主题</span>
<span className="ml-auto truncate text-muted-foreground text-xs">
{getSceneThemeLabel(activeTheme.id, activeTheme.name)}
</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="min-w-48 rounded-xl border-border/45 bg-popover/95 backdrop-blur-xl">
{SCENE_THEMES.map((theme) => {
const swatches = (['wall', 'roof', 'floor', 'glazing'] as const).map(
(role) => theme.clayTints?.[role] ?? CLAY_PALETTE[role],
)
return (
<DropdownMenuItem key={theme.id} onSelect={() => setSceneTheme(theme.id)}>
<span
className="grid h-5 w-5 shrink-0 grid-cols-2 overflow-hidden rounded-sm border border-black/10"
style={{ backgroundColor: theme.background }}
>
{swatches.map((color, index) => (
<span key={`${theme.id}-${index}`} style={{ backgroundColor: color }} />
))}
</span>
<span className="text-foreground">
{getSceneThemeLabel(theme.id, theme.name)}
</span>
{sceneTheme === theme.id ? (
<Check className="ml-auto h-4 w-4 text-foreground" />
) : null}
</DropdownMenuItem>
)
})}
</DropdownMenuSubContent>
</DropdownMenuSub>
</DropdownMenuContent>
</DropdownMenu>
)
}
function WalkthroughButton() {
const isFirstPersonMode = useEditor((state) => state.isFirstPersonMode)
const setFirstPersonMode = useEditor((state) => state.setFirstPersonMode)
const handleClick = useCallback(() => {
if (isFirstPersonMode) {
setFirstPersonMode(false)
return
}
flushSync(() => setFirstPersonMode(true))
requestWalkthroughPointerLock()
}, [isFirstPersonMode, setFirstPersonMode])
return (
<ToolbarTooltip label="漫游模式">
<button
className={cn(
TOOLBAR_BTN,
isFirstPersonMode && 'bg-emerald-500/15 text-emerald-400 hover:bg-emerald-500/20',
)}
onClick={handleClick}
type="button"
>
<Footprints className="h-4 w-4" />
</button>
</ToolbarTooltip>
)
}
function PreviewButton() {
return (
<ToolbarTooltip label="预览模式">
<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>预览</span>
</button>
</ToolbarTooltip>
)
}
export function CommunityViewerToolbarLeft() {
return (
<>
<CollapseSidebarButton />
<ViewModeControl />
</>
)
}
export function CommunityViewerToolbarRight() {
return (
<div className={TOOLBAR_CONTAINER}>
<LevelModeToggle />
<WallModeToggle />
<div className="my-1.5 w-px bg-border/50" />
<DisplayMenu />
<div className="my-1.5 w-px bg-border/50" />
<WalkthroughButton />
<PreviewButton />
</div>
)
}

View File

@ -0,0 +1,37 @@
/**
* Environment variable validation for the editor app.
*
* 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
*/
import { createEnv } from '@t3-oss/env-nextjs'
import { z } from 'zod'
export const env = createEnv({
/**
* Server-side environment variables (not exposed to client)
*/
server: {},
/**
* Client-side environment variables (exposed to browser via NEXT_PUBLIC_)
*/
client: {
NEXT_PUBLIC_ASSETS_CDN_URL: z.string().optional(),
},
/**
* Runtime values - pulls from process.env
*/
runtimeEnv: {
NEXT_PUBLIC_ASSETS_CDN_URL:
process.env.NEXT_PUBLIC_ASSETS_CDN_URL ?? process.env.NEXT_PUBLIC_EDITOR_ASSETS_CDN_URL,
},
/**
* Skip validation during build (env vars come from Vercel at runtime)
*/
skipValidation: !!process.env.SKIP_ENV_VALIDATION,
})

View File

@ -0,0 +1,88 @@
import {
type AnyNodeDefinition,
discoverPlugins,
loadPlugin,
nodeRegistry,
registerNode,
setPluginDiscovery,
} from '@pascal-app/core'
import { builtinPlugin } from '@pascal-app/nodes'
import { treesPlugin } from '@pascal-app/plugin-trees'
// Idempotency guards: HMR can reload this module, but `registerNode`
// throws on duplicate kinds. Flags live in the module closure so they
// reset on a hard reload but survive within a session.
let builtinsLoaded = false
let externalsKickedOff = false
function isDev(): boolean {
const env = (globalThis as { process?: { env?: Record<string, string | undefined> } }).process
?.env
return env?.NODE_ENV !== 'production'
}
/**
* Synchronously register every built-in node kind. Runs as a side
* effect at module import time so the registry is populated *before*
* any downstream React tree renders — the previous async kick-off
* (`void loadBuiltinNodes()`) only registered in a microtask, letting
* the first SSR / hydration pass see an empty registry. The mismatch
* surfaced as a hydration error at the `<html>` element and every
* `NodeRenderer` resolving to `null` until later renders.
*
* `discoverPlugins()` (which may hit the network for external packs)
* stays async and runs separately via `loadExternalPlugins()`.
*/
function loadBuiltinsSync(): void {
if (builtinsLoaded) return
builtinsLoaded = true
for (const def of builtinPlugin.nodes ?? []) {
// Skip kinds the registry already has. The module-closure flag
// above resets on HMR, but the registry singleton (in @pascal-app/core)
// persists — without this guard we'd throw on the first duplicate.
if (nodeRegistry.has((def as AnyNodeDefinition).kind)) continue
registerNode(def as AnyNodeDefinition)
}
if (isDev()) {
const kinds = Array.from(nodeRegistry.entries(), ([k]) => k)
if (typeof console !== 'undefined') {
console.info(
`[pascal:registry] loaded ${builtinPlugin.id} v${builtinPlugin.apiVersion} (${kinds.length} kinds: ${kinds.join(', ') || '∅'})`,
)
}
// Expose the registry on globalThis for ad-hoc dev inspection. In
// prod the registry is reachable through @pascal-app/core's
// exports only.
if (typeof globalThis !== 'undefined') {
;(globalThis as { __pascalNodeRegistry?: typeof nodeRegistry }).__pascalNodeRegistry =
nodeRegistry
}
}
}
/**
* Phase 6 plugin discovery hook — runs once, asynchronously, after the
* synchronous builtins are already registered. Apps that ship external
* node packs override the discovery via `setPluginDiscovery(...)`
* before this module loads. See `wiki/architecture/plugin-authoring.md`.
*/
export async function loadExternalPlugins(): Promise<void> {
if (externalsKickedOff) return
externalsKickedOff = true
const externals = await discoverPlugins()
for (const plugin of externals) {
await loadPlugin(plugin)
}
if (isDev() && externals.length > 0 && typeof console !== 'undefined') {
console.info(`[pascal:registry] + ${externals.length} discovered plugin(s)`)
}
}
// Register the first-party example plugin (trees node + presets rail panel)
// through the same discovery hook a third-party pack would use. Must be set
// before `loadExternalPlugins()` reads it below.
setPluginDiscovery(async () => [treesPlugin])
loadBuiltinsSync()
void loadExternalPlugins()

View File

@ -0,0 +1,34 @@
import { AnyNode } from '@pascal-app/core/schema'
import { z } from 'zod'
/**
* Validates a SceneGraph at an untrusted API boundary. Re-runs
* `AnyNode.safeParse` on every node, which enforces the `AssetUrl`
* allowlist in core (closes the Phase 3 SSRF / arbitrary-URL risk on
* scan/guide/item/material fields).
*
* Shared between `POST /api/scenes` and `PUT /api/scenes/[id]` so neither
* route can silently accept malicious URLs via the `graph` payload.
*
* Phase 8 P4 found the POST bypass; Phase 10 A2 found the PUT bypass.
*/
export const apiGraphSchema = z
.object({
nodes: z.record(z.string(), z.unknown()),
rootNodeIds: z.array(z.string()),
collections: z.unknown().optional(),
})
.superRefine((value, ctx) => {
for (const [nodeId, node] of Object.entries(value.nodes)) {
const res = AnyNode.safeParse(node)
if (!res.success) {
for (const issue of res.error.issues) {
ctx.addIssue({
code: 'custom',
path: ['nodes', nodeId, ...issue.path],
message: issue.message,
})
}
}
}
})

View File

@ -0,0 +1,25 @@
import { timingSafeEqual } from 'node:crypto'
export const PASCAL_AGENT_PROXY_HEADER = 'x-pascal-agent-proxy-secret'
export type PascalAgentAccessResult = 'allowed' | 'missing_proxy_secret' | 'unauthorized'
function safeEqual(left: string, right: string): boolean {
const leftBuffer = Buffer.from(left)
const rightBuffer = Buffer.from(right)
if (leftBuffer.length !== rightBuffer.length) return false
return timingSafeEqual(leftBuffer, rightBuffer)
}
export function validatePascalAgentAccess(
headers: Headers,
env: NodeJS.ProcessEnv = process.env,
): PascalAgentAccessResult {
const expected = env.PASCAL_AGENT_PROXY_SECRET?.trim() ?? ''
if (!expected) {
return env.NODE_ENV === 'production' ? 'missing_proxy_secret' : 'allowed'
}
const supplied = headers.get(PASCAL_AGENT_PROXY_HEADER)?.trim() ?? ''
return supplied && safeEqual(supplied, expected) ? 'allowed' : 'unauthorized'
}

View File

@ -0,0 +1,104 @@
import { describe, expect, test } from 'bun:test'
import { PASCAL_AGENT_PROXY_HEADER, validatePascalAgentAccess } from './pascal-agent-access'
import { getPascalAgentConfig } from './pascal-agent-config'
import { isPascalAgentToolAllowed } from './pascal-agent-mcp'
import { buildPascalAgentPrompt } from './pascal-agent-prompt'
import { getPascalHostIdentity } from './pascal-host-identity'
describe('Pascal agent configuration', () => {
test('stays disabled until a model API key is configured', () => {
const config = getPascalAgentConfig({})
expect(config.configured).toBe(false)
expect(config.baseUrl).toBe('https://api.openai.com/v1')
expect(config.maxSteps).toBe(18)
})
test('normalizes endpoints and clamps safety limits', () => {
const config = getPascalAgentConfig({
PASCAL_AGENT_API_KEY: ' test-key ',
PASCAL_AGENT_BASE_URL: 'https://models.example/v1/',
PASCAL_AGENT_MAX_STEPS: '999',
PASCAL_AGENT_MODEL: ' design-model ',
PASCAL_AGENT_STEP_TIMEOUT_MS: '1000',
PASCAL_AGENT_TOTAL_TIMEOUT_MS: '9999999',
})
expect(config).toMatchObject({
apiKey: 'test-key',
baseUrl: 'https://models.example/v1',
configured: true,
maxSteps: 40,
model: 'design-model',
stepTimeoutMs: 10_000,
totalTimeoutMs: 600_000,
})
})
})
describe('Pascal agent proxy access', () => {
test('allows local development without a proxy secret', () => {
expect(validatePascalAgentAccess(new Headers(), { NODE_ENV: 'development' })).toBe('allowed')
})
test('requires a configured and matching proxy secret in production', () => {
expect(validatePascalAgentAccess(new Headers(), { NODE_ENV: 'production' })).toBe(
'missing_proxy_secret',
)
const headers = new Headers({ [PASCAL_AGENT_PROXY_HEADER]: 'proxy-secret' })
expect(
validatePascalAgentAccess(headers, {
NODE_ENV: 'production',
PASCAL_AGENT_PROXY_SECRET: 'proxy-secret',
}),
).toBe('allowed')
expect(
validatePascalAgentAccess(new Headers(), {
NODE_ENV: 'production',
PASCAL_AGENT_PROXY_SECRET: 'proxy-secret',
}),
).toBe('unauthorized')
})
})
describe('Pascal agent scene isolation', () => {
test('blocks tools that can switch or destroy projects', () => {
expect(isPascalAgentToolAllowed('load_scene')).toBe(false)
expect(isPascalAgentToolAllowed('delete_scene')).toBe(false)
expect(isPascalAgentToolAllowed('create_room')).toBe(true)
expect(isPascalAgentToolAllowed('save_scene')).toBe(true)
})
test('binds the system prompt to the active scene', () => {
const prompt = buildPascalAgentPrompt({ sceneId: 'scene-12', sceneName: 'Loft' })
expect(prompt).toContain('scene-12')
expect(prompt).toContain('Loft')
expect(prompt).toContain('validate_scene')
expect(prompt).toContain('check_collisions')
})
})
describe('Pascal host identity', () => {
test('requires a trusted host user in production', () => {
const missing = getPascalHostIdentity(new Headers(), { NODE_ENV: 'production' })
const present = getPascalHostIdentity(
new Headers({ 'x-pascal-user-id': '0de0ce74-ff6f-48c5-a719-9ad70e688ab8' }),
{ NODE_ENV: 'production' },
)
expect(missing.allowed).toBe(false)
expect(present.allowed).toBe(true)
expect(present.userId).toBe('0de0ce74-ff6f-48c5-a719-9ad70e688ab8')
})
test('rejects malformed forwarded identities', () => {
const identity = getPascalHostIdentity(new Headers({ 'x-pascal-user-id': '../another-user' }), {
NODE_ENV: 'production',
})
expect(identity.allowed).toBe(false)
expect(identity.userId).toBeNull()
})
})

View File

@ -0,0 +1,39 @@
export interface PascalAgentConfig {
apiKey: string
baseUrl: string
configured: boolean
maxSteps: number
model: string
stepTimeoutMs: number
totalTimeoutMs: number
}
const DEFAULT_BASE_URL = 'https://api.openai.com/v1'
const DEFAULT_MODEL = 'gpt-4.1-mini'
function readBoundedInteger(
value: string | undefined,
fallback: number,
minimum: number,
maximum: number,
): number {
const parsed = Number.parseInt(value ?? '', 10)
if (!Number.isFinite(parsed)) return fallback
return Math.min(maximum, Math.max(minimum, parsed))
}
export function getPascalAgentConfig(env: NodeJS.ProcessEnv = process.env): PascalAgentConfig {
const apiKey = env.PASCAL_AGENT_API_KEY?.trim() ?? ''
const baseUrl = (env.PASCAL_AGENT_BASE_URL?.trim() || DEFAULT_BASE_URL).replace(/\/$/, '')
const model = env.PASCAL_AGENT_MODEL?.trim() || DEFAULT_MODEL
return {
apiKey,
baseUrl,
configured: apiKey.length > 0 && model.length > 0,
maxSteps: readBoundedInteger(env.PASCAL_AGENT_MAX_STEPS, 18, 4, 40),
model,
stepTimeoutMs: readBoundedInteger(env.PASCAL_AGENT_STEP_TIMEOUT_MS, 90_000, 10_000, 180_000),
totalTimeoutMs: readBoundedInteger(env.PASCAL_AGENT_TOTAL_TIMEOUT_MS, 300_000, 30_000, 600_000),
}
}

View File

@ -0,0 +1,205 @@
import { existsSync } from 'node:fs'
import path from 'node:path'
import { experimental_createMCPClient, type experimental_MCPClient } from '@ai-sdk/mcp'
import { Experimental_StdioMCPTransport } from '@ai-sdk/mcp/mcp-stdio'
import type { ModelMessage, ToolSet } from 'ai'
const BLOCKED_TOOL_NAMES = new Set([
'create_project',
'delete_scene',
'generate_variants',
'get_project_status',
'list_scenes',
'load_scene',
'rename_scene',
])
type RequiredTool = ToolSet[string] & {
execute: NonNullable<ToolSet[string]['execute']>
}
export interface PascalAgentMcpSession {
close: () => Promise<void>
saveDraft: (abortSignal?: AbortSignal) => Promise<void>
sceneName: string
tools: ToolSet
}
function stringEnvironment(env: NodeJS.ProcessEnv): Record<string, string> {
return Object.fromEntries(
Object.entries(env).filter((entry): entry is [string, string] => entry[1] !== undefined),
)
}
function resolveMcpEntry(env: NodeJS.ProcessEnv): string {
const configured = env.PASCAL_MCP_ENTRY?.trim()
if (configured) return path.resolve(configured)
const candidates = [
path.resolve(process.cwd(), '../../packages/mcp/dist/bin/pascal-mcp.js'),
path.resolve(process.cwd(), 'packages/mcp/dist/bin/pascal-mcp.js'),
path.resolve(process.cwd(), '../packages/mcp/dist/bin/pascal-mcp.js'),
]
const match = candidates.find((candidate) => existsSync(candidate))
if (!match) {
throw new Error(
'Pascal MCP executable was not found. Build @pascal-app/mcp or set PASCAL_MCP_ENTRY.',
)
}
return match
}
function resolveMcpCommand(env: NodeJS.ProcessEnv): string {
const configured = env.PASCAL_MCP_COMMAND?.trim()
if (configured) return configured
return process.versions.bun ? process.execPath : 'bun'
}
function toolExecutionOptions(abortSignal?: AbortSignal) {
return {
abortSignal,
messages: [] as ModelMessage[],
toolCallId: `pascal-agent-${crypto.randomUUID()}`,
}
}
function requireTool(tools: ToolSet, name: string): RequiredTool {
const candidate = tools[name]
if (!candidate?.execute) {
throw new Error(`Pascal MCP tool is unavailable: ${name}`)
}
return candidate as RequiredTool
}
function withStepTimeout(tool: RequiredTool, timeoutMs: number): RequiredTool {
return {
...tool,
execute: async (input, options) => {
const timeoutSignal = AbortSignal.timeout(timeoutMs)
const abortSignal = options.abortSignal
? AbortSignal.any([options.abortSignal, timeoutSignal])
: timeoutSignal
return tool.execute(input, { ...options, abortSignal })
},
}
}
export function isPascalAgentToolAllowed(name: string): boolean {
return !BLOCKED_TOOL_NAMES.has(name)
}
function readSceneName(result: unknown, fallback: string): string {
const structured = (result as { structuredContent?: { name?: unknown } })?.structuredContent
return typeof structured?.name === 'string' && structured.name.trim()
? structured.name.trim()
: fallback
}
function readSceneVersion(result: unknown): number | null {
const version = (result as { structuredContent?: { version?: unknown } })?.structuredContent
?.version
return typeof version === 'number' && Number.isInteger(version) && version > 0 ? version : null
}
function assertToolSucceeded(result: unknown, operation: string): void {
if ((result as { isError?: unknown })?.isError === true) {
throw new Error(`Pascal MCP ${operation} failed.`)
}
}
export async function createPascalAgentMcpSession(params: {
abortSignal?: AbortSignal
env?: NodeJS.ProcessEnv
sceneId: string
stepTimeoutMs: number
}): Promise<PascalAgentMcpSession> {
const env = params.env ?? process.env
const entry = resolveMcpEntry(env)
const transport = new Experimental_StdioMCPTransport({
args: [entry, '--stdio'],
command: resolveMcpCommand(env),
cwd: path.resolve(path.dirname(entry), '../../../..'),
env: stringEnvironment(env),
stderr: 'inherit',
})
let client: experimental_MCPClient | null = null
try {
client = await experimental_createMCPClient({
name: 'pascal-editor-agent',
transport,
version: '0.1.0',
})
const allTools = (await client.tools()) as ToolSet
const loadScene = requireTool(allTools, 'load_scene')
const loadResult = await loadScene.execute(
{ id: params.sceneId },
toolExecutionOptions(params.abortSignal),
)
assertToolSucceeded(loadResult, 'load_scene')
const sceneName = readSceneName(loadResult, params.sceneId)
let currentVersion = readSceneVersion(loadResult)
const saveScene = withStepTimeout(requireTool(allTools, 'save_scene'), params.stepTimeoutMs)
const scopedSaveScene: RequiredTool = {
...saveScene,
execute: async (input, options) => {
const candidate = input && typeof input === 'object' ? input : {}
const result = await saveScene.execute(
{
...candidate,
expectedVersion: currentVersion ?? undefined,
graph: undefined,
id: params.sceneId,
includeCurrentScene: true,
name: sceneName,
projectId: undefined,
},
options,
)
const savedVersion = readSceneVersion(result)
if (savedVersion) currentVersion = savedVersion
return result
},
}
const tools = Object.fromEntries(
Object.entries(allTools)
.filter(([name]) => isPascalAgentToolAllowed(name))
.map(([name, tool]) => [
name,
name === 'save_scene'
? scopedSaveScene
: withStepTimeout(tool as RequiredTool, params.stepTimeoutMs),
]),
) as ToolSet
let closed = false
const close = async () => {
if (closed) return
closed = true
await client?.close()
}
return {
close,
sceneName,
tools,
saveDraft: async (abortSignal) => {
const result = await scopedSaveScene.execute(
{
id: params.sceneId,
includeCurrentScene: true,
name: sceneName,
saveMode: 'draft',
},
toolExecutionOptions(abortSignal),
)
assertToolSucceeded(result, 'save_scene')
},
}
} catch (error) {
await client?.close().catch(() => undefined)
throw error
}
}

View File

@ -0,0 +1,17 @@
export function buildPascalAgentPrompt(params: { sceneId: string; sceneName: string }): string {
return `You are the autonomous architectural design agent embedded in Pascal Editor.
You are already bound to scene "${params.sceneName}" (${params.sceneId}). Never create, load, rename, list, or delete another project. Work only on the current scene.
Operating rules:
- Interpret the user's design intent, make reasonable architectural defaults, and start working without asking questions unless a missing constraint makes the task unsafe or impossible.
- Use semantic Pascal tools before low-level patches: create_house_from_brief, create_story_shell, create_room, add_door, add_window, create_stair_between_levels, create_roof, and furnish_room.
- Inspect the current scene before changing an existing design. Preserve unrelated work.
- Pascal uses metres, with X/Z on the ground plane and Y pointing up.
- Build in coherent groups. Call save_scene with saveMode "draft" after each meaningful group so the open editor updates while you work.
- Before finishing, call validate_scene and check_collisions. Repair structural errors and serious collisions, then save_scene with saveMode "checkpoint".
- Never claim a change succeeded unless the corresponding tool succeeded.
- Keep destructive edits narrowly scoped. Prefer update or targeted delete_node operations and preserve the ability to undo.
- Respond in the user's language. Keep progress updates concise and finish with a short summary of what changed and any assumptions.
`
}

View File

@ -0,0 +1,10 @@
const configuredBasePath = process.env.NEXT_PUBLIC_PASCAL_BASE_PATH?.trim() ?? ''
export const PASCAL_BASE_PATH = configuredBasePath
? `/${configuredBasePath.replace(/^\/+|\/+$/g, '')}`
: ''
export function withPascalBasePath(pathname: string): string {
const path = pathname.startsWith('/') ? pathname : `/${pathname}`
return `${PASCAL_BASE_PATH}${path}`
}

View File

@ -0,0 +1,24 @@
export const PASCAL_HOST_USER_HEADER = 'x-pascal-user-id'
export interface PascalHostIdentity {
allowed: boolean
required: boolean
userId: string | null
}
export function getPascalHostIdentity(
headers: Headers,
env: NodeJS.ProcessEnv = process.env,
): PascalHostIdentity {
const required =
env.PASCAL_REQUIRE_HOST_USER === 'true' ||
(env.NODE_ENV === 'production' && env.PASCAL_REQUIRE_HOST_USER !== 'false')
const supplied = headers.get(PASCAL_HOST_USER_HEADER)?.trim() ?? ''
const userId = /^[a-zA-Z0-9_-]{1,128}$/.test(supplied) ? supplied : null
return {
allowed: Boolean(userId) || !required,
required,
userId,
}
}

View File

@ -0,0 +1,64 @@
import { afterEach, expect, test } from 'bun:test'
import { guardSceneApiRequest, sceneApiPreflight } from './scene-api-security'
const OLD_ENV = { ...process.env }
afterEach(() => {
restoreEnv('PASCAL_SCENE_API_TOKEN')
restoreEnv('PASCAL_SCENE_API_ORIGINS')
restoreEnv('PASCAL_SCENE_API_RATE_LIMIT')
})
function restoreEnv(key: keyof NodeJS.ProcessEnv): void {
if (OLD_ENV[key] === undefined) delete process.env[key]
else process.env[key] = OLD_ENV[key]
}
test('allows loopback scene API requests without a token', () => {
delete process.env.PASCAL_SCENE_API_TOKEN
const request = new Request('http://127.0.0.1:3000/api/scenes', {
headers: { host: '127.0.0.1:3000' },
})
expect(guardSceneApiRequest(request)).toBeNull()
})
test('requires a token for non-loopback scene API requests', async () => {
delete process.env.PASCAL_SCENE_API_TOKEN
const request = new Request('https://editor.example/api/scenes', {
headers: { host: 'editor.example' },
})
const response = guardSceneApiRequest(request)
expect(response?.status).toBe(503)
expect(await response?.json()).toEqual({ error: 'scene_api_token_required' })
})
test('accepts bearer token auth when configured', () => {
process.env.PASCAL_SCENE_API_TOKEN = 'secret'
const request = new Request('https://editor.example/api/scenes', {
headers: {
authorization: 'Bearer secret',
host: 'editor.example',
},
})
expect(guardSceneApiRequest(request)).toBeNull()
})
test('applies configured CORS origins for preflight', () => {
process.env.PASCAL_SCENE_API_ORIGINS = 'https://app.example'
const request = new Request('https://editor.example/api/scenes', {
method: 'OPTIONS',
headers: {
host: 'editor.example',
origin: 'https://app.example',
},
})
const response = sceneApiPreflight(request)
expect(response.status).toBe(204)
expect(response.headers.get('access-control-allow-origin')).toBe('https://app.example')
})

View File

@ -0,0 +1,180 @@
import { timingSafeEqual } from 'node:crypto'
import { NextResponse } from 'next/server'
const DEFAULT_RATE_LIMIT_PER_MINUTE = 120
const WINDOW_MS = 60_000
const ALLOWED_METHODS = 'GET, POST, PUT, PATCH, DELETE, OPTIONS'
const ALLOWED_HEADERS = 'authorization, content-type, if-match, last-event-id, x-pascal-scene-token'
type RateBucket = {
resetAt: number
count: number
}
const rateBuckets = new Map<string, RateBucket>()
export function sceneApiPreflight(request: Request): NextResponse {
const guard = guardSceneApiRequest(request, { skipRateLimit: true, skipAuth: true })
if (guard) return guard
return withSceneApiHeaders(request, new NextResponse(null, { status: 204 }))
}
export function guardSceneApiRequest(
request: Request,
opts: { skipRateLimit?: boolean; skipAuth?: boolean } = {},
): NextResponse | null {
const originError = validateOrigin(request)
if (originError) return originError
if (!opts.skipAuth) {
const authError = validateAuth(request)
if (authError) return authError
}
if (!opts.skipRateLimit) {
const rateError = validateRateLimit(request)
if (rateError) return rateError
}
return null
}
export function sceneApiJson(request: Request, body: unknown, init?: ResponseInit): NextResponse {
return withSceneApiHeaders(request, NextResponse.json(body, init))
}
export function withSceneApiHeaders<T extends Response>(request: Request, response: T): T {
const origin = request.headers.get('origin')
if (origin && isOriginAllowed(request, origin)) {
response.headers.set('Access-Control-Allow-Origin', origin)
response.headers.append('Vary', 'Origin')
}
response.headers.set('Access-Control-Allow-Methods', ALLOWED_METHODS)
response.headers.set('Access-Control-Allow-Headers', ALLOWED_HEADERS)
response.headers.set('Cache-Control', response.headers.get('Cache-Control') ?? 'no-store')
response.headers.set('X-Content-Type-Options', 'nosniff')
return response
}
function validateOrigin(request: Request): NextResponse | null {
const origin = request.headers.get('origin')
if (!origin || isOriginAllowed(request, origin)) return null
return sceneApiJson(request, { error: 'origin_not_allowed' }, { status: 403 })
}
function validateAuth(request: Request): NextResponse | null {
const token = process.env.PASCAL_SCENE_API_TOKEN
if (!token) {
if (isLoopbackRequest(request)) return null
return sceneApiJson(request, { error: 'scene_api_token_required' }, { status: 503 })
}
const supplied = bearerToken(request) ?? request.headers.get('x-pascal-scene-token')
if (supplied && safeEqual(supplied, token)) return null
return sceneApiJson(request, { error: 'unauthorized' }, { status: 401 })
}
function validateRateLimit(request: Request): NextResponse | null {
const limit = rateLimitPerMinute()
if (limit <= 0) return null
const now = Date.now()
const key = clientIp(request)
const bucket = rateBuckets.get(key)
if (!bucket || bucket.resetAt <= now) {
rateBuckets.set(key, { count: 1, resetAt: now + WINDOW_MS })
return null
}
bucket.count++
if (bucket.count <= limit) return null
const retryAfter = Math.max(1, Math.ceil((bucket.resetAt - now) / 1000))
const response = sceneApiJson(request, { error: 'rate_limited' }, { status: 429 })
response.headers.set('Retry-After', String(retryAfter))
return response
}
function bearerToken(request: Request): string | null {
const header = request.headers.get('authorization')
if (!header) return null
const match = header.match(/^Bearer\s+(.+)$/i)
return match?.[1] ?? null
}
function safeEqual(a: string, b: string): boolean {
const aBuffer = Buffer.from(a)
const bBuffer = Buffer.from(b)
if (aBuffer.length !== bBuffer.length) return false
return timingSafeEqual(aBuffer, bBuffer)
}
function rateLimitPerMinute(): number {
const raw = process.env.PASCAL_SCENE_API_RATE_LIMIT
if (!raw) return DEFAULT_RATE_LIMIT_PER_MINUTE
const n = Number.parseInt(raw, 10)
return Number.isFinite(n) ? n : DEFAULT_RATE_LIMIT_PER_MINUTE
}
function clientIp(request: Request): string {
const forwarded = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim()
if (forwarded) return forwarded
return request.headers.get('x-real-ip') ?? 'unknown'
}
function isOriginAllowed(request: Request, origin: string): boolean {
if (isSameOrigin(request, origin)) return true
const parsed = parseUrl(origin)
if (!parsed) return false
if (isLoopbackHostname(parsed.hostname)) return true
return configuredOrigins().has(normalizeOrigin(parsed))
}
function configuredOrigins(): Set<string> {
const raw = process.env.PASCAL_SCENE_API_ORIGINS
if (!raw) return new Set()
return new Set(
raw
.split(',')
.map((part) => parseUrl(part.trim()))
.filter((url): url is URL => url !== null)
.map(normalizeOrigin),
)
}
function isSameOrigin(request: Request, origin: string): boolean {
const parsedOrigin = parseUrl(origin)
if (!parsedOrigin) return false
const requestUrl = new URL(request.url)
return normalizeOrigin(parsedOrigin) === normalizeOrigin(requestUrl)
}
function isLoopbackRequest(request: Request): boolean {
const host = request.headers.get('host') ?? new URL(request.url).host
return isLoopbackHostname(stripPort(host))
}
function isLoopbackHostname(hostname: string): boolean {
const h = hostname.toLowerCase()
return h === 'localhost' || h.endsWith('.localhost') || h === '127.0.0.1' || h === '::1'
}
function parseUrl(value: string): URL | null {
try {
return new URL(value)
} catch {
return null
}
}
function normalizeOrigin(url: URL): string {
return `${url.protocol}//${url.host}`.toLowerCase()
}
function stripPort(host: string): string {
if (host.startsWith('[')) {
const end = host.indexOf(']')
return end === -1 ? host : host.slice(1, end)
}
return host.split(':')[0] ?? host
}

View File

@ -0,0 +1,74 @@
import { beforeEach, describe, expect, mock, test } from 'bun:test'
describe('getSceneStore', () => {
beforeEach(() => {
mock.module('@pascal-app/mcp/operations', () => ({
createSceneOperations: ({ store }: { store: unknown }) => ({
__store: store,
hasStore: true,
}),
}))
mock.module('@pascal-app/mcp/storage', () => {
let callCount = 0
return {
createSceneStore: async (_env?: NodeJS.ProcessEnv) => {
callCount++
return {
backend: 'sqlite' as const,
__instanceNumber: callCount,
save: async () => ({}) as never,
load: async () => null,
list: async () => [],
delete: async () => false,
rename: async () => ({}) as never,
}
},
}
})
})
test('returns the same promise on repeated calls', async () => {
const mod = await import('./scene-store-server')
mod.__resetSceneStoreForTests()
const a = mod.getSceneStore()
const b = mod.getSceneStore()
expect(a).toBe(b)
})
test('resolves to the same store instance across calls', async () => {
const mod = await import('./scene-store-server')
mod.__resetSceneStoreForTests()
const storeA = await mod.getSceneStore()
const storeB = await mod.getSceneStore()
expect(storeA).toBe(storeB)
// Factory should have been invoked exactly once — asserted indirectly via
// our mock's instance counter.
expect((storeA as unknown as { __instanceNumber: number }).__instanceNumber).toBe(1)
expect((storeB as unknown as { __instanceNumber: number }).__instanceNumber).toBe(1)
})
test('reset helper clears the cached singleton', async () => {
const mod = await import('./scene-store-server')
mod.__resetSceneStoreForTests()
const first = await mod.getSceneStore()
mod.__resetSceneStoreForTests()
const second = await mod.getSceneStore()
expect(first).not.toBe(second)
})
test('getSceneOperations wraps the cached store', async () => {
const mod = await import('./scene-store-server')
mod.__resetSceneStoreForTests()
const store = await mod.getSceneStore()
const operations = await mod.getSceneOperations()
expect((operations as unknown as { __store: unknown }).__store).toBe(store)
})
})

View File

@ -0,0 +1,44 @@
import type { SceneOperations } from '@pascal-app/mcp/operations'
import type { SceneStore } from '@pascal-app/mcp/storage'
/**
* Per-process singleton. The factory is async because backend modules are
* dynamically imported — we cache the in-flight promise so concurrent calls
* during a cold start share a single instantiation.
*/
let cachedStore: Promise<SceneStore> | null = null
let cachedOperations: Promise<SceneOperations> | null = null
export function getSceneStore(): Promise<SceneStore> {
if (!cachedStore) {
cachedStore = (async () => {
const mod = (await import('@pascal-app/mcp/storage')) as {
createSceneStore: (env?: NodeJS.ProcessEnv) => Promise<SceneStore>
}
return mod.createSceneStore(process.env)
})()
}
return cachedStore
}
export function getSceneOperations(): Promise<SceneOperations> {
if (!cachedOperations) {
cachedOperations = (async () => {
const store = await getSceneStore()
const mod = (await import('@pascal-app/mcp/operations')) as {
createSceneOperations: (options: { store: SceneStore }) => SceneOperations
}
return mod.createSceneOperations({ store })
})()
}
return cachedOperations
}
/**
* Test-only helper to reset the cached singleton. Not exported for production
* callers.
*/
export function __resetSceneStoreForTests(): void {
cachedStore = null
cachedOperations = null
}

View File

@ -0,0 +1,43 @@
import { type ClassValue, clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
export const isDevelopment =
process.env.NODE_ENV === 'development' || process.env.NEXT_PUBLIC_VERCEL_ENV === 'development'
export const isProduction =
process.env.NODE_ENV === 'production' || process.env.NEXT_PUBLIC_VERCEL_ENV === 'production'
export const isPreview = process.env.NEXT_PUBLIC_VERCEL_ENV === 'preview'
/**
* Base URL for the application
* Uses NEXT_PUBLIC_* variables which are available at build time
*/
export const BASE_URL = (() => {
// Development: localhost
if (isDevelopment) {
return process.env.NEXT_PUBLIC_APP_URL || `http://localhost:${process.env.PORT || 3000}`
}
// Preview deployments: use Vercel branch URL
if (isPreview && process.env.NEXT_PUBLIC_VERCEL_URL) {
return `https://${process.env.NEXT_PUBLIC_VERCEL_URL}`
}
// Production: use custom domain or Vercel production URL
if (isProduction) {
return (
process.env.NEXT_PUBLIC_APP_URL ||
(process.env.NEXT_PUBLIC_VERCEL_PROJECT_PRODUCTION_URL
? `https://${process.env.NEXT_PUBLIC_VERCEL_PROJECT_PRODUCTION_URL}`
: 'https://editor.pascal.app')
)
}
// Fallback (should never reach here in normal operation)
return process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'
})()

View File

@ -0,0 +1,51 @@
import type { NextConfig } from 'next'
const configuredBasePath = process.env.NEXT_PUBLIC_PASCAL_BASE_PATH?.trim() ?? ''
const basePath = configuredBasePath ? `/${configuredBasePath.replace(/^\/+|\/+$/g, '')}` : undefined
const nextConfig: NextConfig = {
basePath,
logging: {
browserToTerminal: true,
},
typescript: {
ignoreBuildErrors: true,
},
transpilePackages: [
'three',
'@pascal-app/viewer',
'@pascal-app/core',
'@pascal-app/editor',
'@pascal-app/mcp',
'@pascal-app/plugin-trees',
'@dgreenheck/ez-tree',
],
turbopack: {
resolveAlias: {
react: './node_modules/react',
three: './node_modules/three',
'@react-three/fiber': './node_modules/@react-three/fiber',
'@react-three/drei': './node_modules/@react-three/drei',
},
},
experimental: {
serverActions: {
bodySizeLimit: '100mb',
},
},
images: {
unoptimized: process.env.NEXT_PUBLIC_ASSETS_CDN_URL?.startsWith('http://localhost') ?? false,
remotePatterns: [
{
protocol: 'https',
hostname: '**',
},
{
protocol: 'http',
hostname: '**',
},
],
},
}
export default nextConfig

View File

@ -0,0 +1,54 @@
{
"name": "editor",
"version": "0.1.0",
"type": "module",
"private": true,
"scripts": {
"dev": "dotenv -e ../../.env.local -- bun --bun next dev --port 3002",
"build": "dotenv -e ../../.env.local -- next build",
"start": "bun --bun next start",
"lint": "biome lint",
"check-types": "next typegen && tsgo --noEmit"
},
"dependencies": {
"@ai-sdk/mcp": "0.0.23",
"@ai-sdk/openai": "2.0.13",
"@ai-sdk/react": "2.0.26",
"@iconify/react": "^6.0.2",
"@number-flow/react": "^0.6.0",
"@pascal-app/core": "*",
"@pascal-app/editor": "*",
"@pascal-app/mcp": "*",
"@pascal-app/nodes": "*",
"@pascal-app/plugin-trees": "*",
"@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",
"ai": "5.0.117",
"clsx": "^2.1.1",
"geist": "^1.7.0",
"lucide-react": "^1.7.0",
"next": "16.2.9",
"postcss": "^8.5.6",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.1",
"three": "^0.184.0",
"zod": "^4.3.5"
},
"devDependencies": {
"@pascal/typescript-config": "*",
"@types/howler": "^2.2.12",
"@types/node": "^22.19.12",
"@types/react": "19.2.2",
"@types/react-dom": "19.2.2",
"agentation": "^3.0.2",
"react-grab": "^0.1.29",
"react-scan": "^0.5.3",
"tw-animate-css": "^1.4.0",
"typescript": "6.0.3"
}
}

View File

@ -0,0 +1,5 @@
export default {
plugins: {
'@tailwindcss/postcss': {},
},
}

View File

@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M20.5056 10.7754C21.1225 10.5355 21.431 10.4155 21.5176 10.2459C21.5926 10.099 21.5903 9.92446 21.5115 9.77954C21.4205 9.61226 21.109 9.50044 20.486 9.2768L4.59629 3.5728C4.0866 3.38983 3.83175 3.29835 3.66514 3.35605C3.52029 3.40621 3.40645 3.52004 3.35629 3.6649C3.29859 3.8315 3.39008 4.08635 3.57304 4.59605L9.277 20.4858C9.50064 21.1088 9.61246 21.4203 9.77973 21.5113C9.92465 21.5901 10.0991 21.5924 10.2461 21.5174C10.4157 21.4308 10.5356 21.1223 10.7756 20.5054L13.3724 13.8278C13.4194 13.707 13.4429 13.6466 13.4792 13.5957C13.5114 13.5506 13.5508 13.5112 13.5959 13.479C13.6468 13.4427 13.7072 13.4192 13.828 13.3722L20.5056 10.7754Z" stroke="#D3DAF0" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" fill="#202022"/>
</svg>

After

Width:  |  Height:  |  Size: 859 B

File diff suppressed because it is too large Load Diff

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