Merge commit 'c3faeba1c8c1275c156c8739b50b8f0ca9425fd3' as 'services/pascal-editor'
This commit is contained in:
commit
40ff3ca851
132
services/pascal-editor/.agents/skills/open-pr/SKILL.md
Normal file
132
services/pascal-editor/.agents/skills/open-pr/SKILL.md
Normal 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
|
||||
@ -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
|
||||
7
services/pascal-editor/.claude/settings.json
Normal file
7
services/pascal-editor/.claude/settings.json
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(npx turbo:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
1
services/pascal-editor/.claude/skills
Symbolic link
1
services/pascal-editor/.claude/skills
Symbolic link
@ -0,0 +1 @@
|
||||
../.agents/skills
|
||||
1
services/pascal-editor/.codex/skills
Symbolic link
1
services/pascal-editor/.codex/skills
Symbolic link
@ -0,0 +1 @@
|
||||
../.agents/skills
|
||||
1
services/pascal-editor/.cursor/skills
Symbolic link
1
services/pascal-editor/.cursor/skills
Symbolic link
@ -0,0 +1 @@
|
||||
../.agents/skills
|
||||
33
services/pascal-editor/.env.example
Normal file
33
services/pascal-editor/.env.example
Normal 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
|
||||
64
services/pascal-editor/.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
64
services/pascal-editor/.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal 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
|
||||
8
services/pascal-editor/.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
8
services/pascal-editor/.github/ISSUE_TEMPLATE/config.yml
vendored
Normal 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
|
||||
41
services/pascal-editor/.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
41
services/pascal-editor/.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal 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
|
||||
1
services/pascal-editor/.github/copilot-instructions.md
vendored
Symbolic link
1
services/pascal-editor/.github/copilot-instructions.md
vendored
Symbolic link
@ -0,0 +1 @@
|
||||
../AGENTS.md
|
||||
27
services/pascal-editor/.github/pull_request_template.md
vendored
Normal file
27
services/pascal-editor/.github/pull_request_template.md
vendored
Normal 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
|
||||
28
services/pascal-editor/.github/workflows/ci.yml
vendored
Normal file
28
services/pascal-editor/.github/workflows/ci.yml
vendored
Normal 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
|
||||
52
services/pascal-editor/.github/workflows/mcp-ci.yml
vendored
Normal file
52
services/pascal-editor/.github/workflows/mcp-ci.yml
vendored
Normal 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
|
||||
221
services/pascal-editor/.github/workflows/release.yml
vendored
Normal file
221
services/pascal-editor/.github/workflows/release.yml
vendored
Normal 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
56
services/pascal-editor/.gitignore
vendored
Normal 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
|
||||
0
services/pascal-editor/.npmrc
Normal file
0
services/pascal-editor/.npmrc
Normal file
41
services/pascal-editor/.vscode/settings.json
vendored
Normal file
41
services/pascal-editor/.vscode/settings.json
vendored
Normal 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
|
||||
}
|
||||
52
services/pascal-editor/AGENTS.md
Normal file
52
services/pascal-editor/AGENTS.md
Normal 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*.
|
||||
59
services/pascal-editor/CHANGELOG.md
Normal file
59
services/pascal-editor/CHANGELOG.md
Normal 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
|
||||
1
services/pascal-editor/CLAUDE.md
Symbolic link
1
services/pascal-editor/CLAUDE.md
Symbolic link
@ -0,0 +1 @@
|
||||
AGENTS.md
|
||||
75
services/pascal-editor/CONTRIBUTING.md
Normal file
75
services/pascal-editor/CONTRIBUTING.md
Normal 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!
|
||||
1
services/pascal-editor/GEMINI.md
Symbolic link
1
services/pascal-editor/GEMINI.md
Symbolic link
@ -0,0 +1 @@
|
||||
AGENTS.md
|
||||
21
services/pascal-editor/LICENSE
Normal file
21
services/pascal-editor/LICENSE
Normal 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.
|
||||
429
services/pascal-editor/README.md
Normal file
429
services/pascal-editor/README.md
Normal file
@ -0,0 +1,429 @@
|
||||
# Pascal Editor
|
||||
|
||||
A 3D building editor built with React Three Fiber and WebGPU.
|
||||
|
||||
[](LICENSE)
|
||||
[](https://www.npmjs.com/package/@pascal-app/core)
|
||||
[](https://www.npmjs.com/package/@pascal-app/viewer)
|
||||
[](https://discord.gg/SaBRA9t2)
|
||||
[](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>
|
||||
97
services/pascal-editor/SETUP.md
Normal file
97
services/pascal-editor/SETUP.md
Normal 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.
|
||||
37
services/pascal-editor/apps/editor/.gitignore
vendored
Normal file
37
services/pascal-editor/apps/editor/.gitignore
vendored
Normal 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
|
||||
364
services/pascal-editor/apps/editor/README.md
Normal file
364
services/pascal-editor/apps/editor/README.md
Normal 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 |
|
||||
161
services/pascal-editor/apps/editor/app/api/agent/chat/route.ts
Normal file
161
services/pascal-editor/apps/editor/app/api/agent/chat/route.ts
Normal 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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,3 @@
|
||||
export function GET() {
|
||||
return Response.json({ status: 'ok', app: 'editor', timestamp: new Date().toISOString() })
|
||||
}
|
||||
@ -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',
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
253
services/pascal-editor/apps/editor/app/api/scenes/[id]/route.ts
Normal file
253
services/pascal-editor/apps/editor/app/api/scenes/[id]/route.ts
Normal 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 })
|
||||
}
|
||||
121
services/pascal-editor/apps/editor/app/api/scenes/route.ts
Normal file
121
services/pascal-editor/apps/editor/app/api/scenes/route.ts
Normal 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 })
|
||||
}
|
||||
23
services/pascal-editor/apps/editor/app/client-bootstrap.tsx
Normal file
23
services/pascal-editor/apps/editor/app/client-bootstrap.tsx
Normal 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
|
||||
}
|
||||
BIN
services/pascal-editor/apps/editor/app/favicon.ico
Normal file
BIN
services/pascal-editor/apps/editor/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
BIN
services/pascal-editor/apps/editor/app/fonts/GeistMonoVF.woff
Normal file
BIN
services/pascal-editor/apps/editor/app/fonts/GeistMonoVF.woff
Normal file
Binary file not shown.
BIN
services/pascal-editor/apps/editor/app/fonts/GeistVF.woff
Normal file
BIN
services/pascal-editor/apps/editor/app/fonts/GeistVF.woff
Normal file
Binary file not shown.
369
services/pascal-editor/apps/editor/app/globals.css
Normal file
369
services/pascal-editor/apps/editor/app/globals.css
Normal 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%;
|
||||
}
|
||||
}
|
||||
45
services/pascal-editor/apps/editor/app/layout.tsx
Normal file
45
services/pascal-editor/apps/editor/app/layout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
118
services/pascal-editor/apps/editor/app/page.tsx
Normal file
118
services/pascal-editor/apps/editor/app/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
207
services/pascal-editor/apps/editor/app/privacy/page.tsx
Normal file
207
services/pascal-editor/apps/editor/app/privacy/page.tsx
Normal 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. ("we," "us," or "our") 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'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>
|
||||
)
|
||||
}
|
||||
57
services/pascal-editor/apps/editor/app/scene/[id]/page.tsx
Normal file
57
services/pascal-editor/apps/editor/app/scene/[id]/page.tsx
Normal 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} />
|
||||
}
|
||||
96
services/pascal-editor/apps/editor/app/scenes/page.tsx
Normal file
96
services/pascal-editor/apps/editor/app/scenes/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
180
services/pascal-editor/apps/editor/app/terms/page.tsx
Normal file
180
services/pascal-editor/apps/editor/app/terms/page.tsx
Normal 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 ("Editor") and the Pascal platform at pascal.app
|
||||
("Platform"), operated by Pascal Group Inc. ("we," "us,"
|
||||
or "our"). 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'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 ("Your Content"). 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 "AS IS" AND "AS AVAILABLE" 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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
578
services/pascal-editor/apps/editor/components/build-tab.tsx
Normal file
578
services/pascal-editor/apps/editor/components/build-tab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
158
services/pascal-editor/apps/editor/components/save-button.tsx
Normal file
158
services/pascal-editor/apps/editor/components/save-button.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
264
services/pascal-editor/apps/editor/components/scene-loader.tsx
Normal file
264
services/pascal-editor/apps/editor/components/scene-loader.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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 }
|
||||
545
services/pascal-editor/apps/editor/components/viewer-toolbar.tsx
Normal file
545
services/pascal-editor/apps/editor/components/viewer-toolbar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
37
services/pascal-editor/apps/editor/env.mjs
Normal file
37
services/pascal-editor/apps/editor/env.mjs
Normal 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,
|
||||
})
|
||||
88
services/pascal-editor/apps/editor/lib/bootstrap.ts
Normal file
88
services/pascal-editor/apps/editor/lib/bootstrap.ts
Normal 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()
|
||||
34
services/pascal-editor/apps/editor/lib/graph-schema.ts
Normal file
34
services/pascal-editor/apps/editor/lib/graph-schema.ts
Normal 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,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@ -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'
|
||||
}
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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),
|
||||
}
|
||||
}
|
||||
205
services/pascal-editor/apps/editor/lib/pascal-agent-mcp.ts
Normal file
205
services/pascal-editor/apps/editor/lib/pascal-agent-mcp.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@ -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.
|
||||
`
|
||||
}
|
||||
10
services/pascal-editor/apps/editor/lib/pascal-base-path.ts
Normal file
10
services/pascal-editor/apps/editor/lib/pascal-base-path.ts
Normal 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}`
|
||||
}
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
@ -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')
|
||||
})
|
||||
180
services/pascal-editor/apps/editor/lib/scene-api-security.ts
Normal file
180
services/pascal-editor/apps/editor/lib/scene-api-security.ts
Normal 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
|
||||
}
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
44
services/pascal-editor/apps/editor/lib/scene-store-server.ts
Normal file
44
services/pascal-editor/apps/editor/lib/scene-store-server.ts
Normal 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
|
||||
}
|
||||
43
services/pascal-editor/apps/editor/lib/utils.ts
Normal file
43
services/pascal-editor/apps/editor/lib/utils.ts
Normal 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'
|
||||
})()
|
||||
51
services/pascal-editor/apps/editor/next.config.ts
Normal file
51
services/pascal-editor/apps/editor/next.config.ts
Normal 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
|
||||
54
services/pascal-editor/apps/editor/package.json
Normal file
54
services/pascal-editor/apps/editor/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
5
services/pascal-editor/apps/editor/postcss.config.mjs
Normal file
5
services/pascal-editor/apps/editor/postcss.config.mjs
Normal file
@ -0,0 +1,5 @@
|
||||
export default {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
},
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
3
services/pascal-editor/apps/editor/public/cursor.svg
Normal file
3
services/pascal-editor/apps/editor/public/cursor.svg
Normal 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 |
1408
services/pascal-editor/apps/editor/public/demos/demo_1.json
Normal file
1408
services/pascal-editor/apps/editor/public/demos/demo_1.json
Normal file
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
Loading…
x
Reference in New Issue
Block a user