diff --git a/.gitignore b/.gitignore index 96fab4fe..4405a334 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,7 @@ coverage out/ build dist +*.tsbuildinfo # Debug diff --git a/README.md b/README.md index c23e95a2..b7d9d1c8 100644 --- a/README.md +++ b/README.md @@ -1,135 +1,397 @@ -# Turborepo starter +# Pascal Editor -This Turborepo starter is maintained by the Turborepo core team. +A 3D building editor built with React Three Fiber and WebGPU. -## Using this example +## Repository Architecture -Run the following command: - -```sh -npx create-turbo@latest -``` - -## What's inside? - -This Turborepo includes the following packages/apps: - -### Apps and Packages - -- `docs`: a [Next.js](https://nextjs.org/) app -- `web`: another [Next.js](https://nextjs.org/) app -- `@repo/ui`: a stub React component library shared by both `web` and `docs` applications -- `@repo/eslint-config`: `eslint` configurations (includes `eslint-config-next` and `eslint-config-prettier`) -- `@repo/typescript-config`: `tsconfig.json`s used throughout the monorepo - -Each package/app is 100% [TypeScript](https://www.typescriptlang.org/). - -### Utilities - -This Turborepo has some additional tools already setup for you: - -- [TypeScript](https://www.typescriptlang.org/) for static type checking -- [ESLint](https://eslint.org/) for code linting -- [Prettier](https://prettier.io) for code formatting - -### Build - -To build all apps and packages, run the following command: +This is a Turborepo monorepo with three main packages: ``` -cd my-turborepo +editor-v2/ +├── apps/ +│ └── editor/ # Next.js application +├── packages/ +│ ├── core/ # Schema definitions, state management, systems +│ └── viewer/ # 3D rendering components +``` -# With [global `turbo`](https://turborepo.dev/docs/getting-started/installation#global-installation) installed (recommended) +### 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`), 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, // All nodes + rootNodeIds: string[], // Top-level nodes (sites) + dirtyNodes: Set, // 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 → 3D object + byType: { + wall: Set, + item: Set, + zone: Set, + // ... + } +} +``` + +Renderers register their refs using the `useRegistry` hook: + +```tsx +const ref = useRef(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(null!) + useRegistry(node.id, 'wall', ref) + + return ( + + {/* Replaced by WallSystem */} + + {node.children.map(id => )} + + ) +} +``` + +--- + +### 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 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 -# Without [global `turbo`](https://turborepo.dev/docs/getting-started/installation#global-installation), use your package manager -npx turbo build -yarn dlx turbo build -pnpm exec turbo build +# Build specific package +turbo build --filter=@pascal-app/core ``` -You can build a specific package by using a [filter](https://turborepo.dev/docs/crafting-your-repository/running-tasks#using-filters): +### Publishing Packages -``` -# With [global `turbo`](https://turborepo.dev/docs/getting-started/installation#global-installation) installed (recommended) -turbo build --filter=docs +```bash +# Build packages +turbo build --filter=@pascal-app/core --filter=@pascal-app/viewer -# Without [global `turbo`](https://turborepo.dev/docs/getting-started/installation#global-installation), use your package manager -npx turbo build --filter=docs -yarn exec turbo build --filter=docs -pnpm exec turbo build --filter=docs +# Publish to npm +npm publish --workspace=@pascal-app/core --access public +npm publish --workspace=@pascal-app/viewer --access public ``` -### Develop +--- -To develop all apps and packages, run the following command: +## Key Files -``` -cd my-turborepo - -# With [global `turbo`](https://turborepo.dev/docs/getting-started/installation#global-installation) installed (recommended) -turbo dev - -# Without [global `turbo`](https://turborepo.dev/docs/getting-started/installation#global-installation), use your package manager -npx turbo dev -yarn exec turbo dev -pnpm exec turbo dev -``` - -You can develop a specific package by using a [filter](https://turborepo.dev/docs/crafting-your-repository/running-tasks#using-filters): - -``` -# With [global `turbo`](https://turborepo.dev/docs/getting-started/installation#global-installation) installed (recommended) -turbo dev --filter=web - -# Without [global `turbo`](https://turborepo.dev/docs/getting-started/installation#global-installation), use your package manager -npx turbo dev --filter=web -yarn exec turbo dev --filter=web -pnpm exec turbo dev --filter=web -``` - -### Remote Caching - -> [!TIP] -> Vercel Remote Cache is free for all plans. Get started today at [vercel.com](https://vercel.com/signup?/signup?utm_source=remote-cache-sdk&utm_campaign=free_remote_cache). - -Turborepo can use a technique known as [Remote Caching](https://turborepo.dev/docs/core-concepts/remote-caching) to share cache artifacts across machines, enabling you to share build caches with your team and CI/CD pipelines. - -By default, Turborepo will cache locally. To enable Remote Caching you will need an account with Vercel. If you don't have an account you can [create one](https://vercel.com/signup?utm_source=turborepo-examples), then enter the following commands: - -``` -cd my-turborepo - -# With [global `turbo`](https://turborepo.dev/docs/getting-started/installation#global-installation) installed (recommended) -turbo login - -# Without [global `turbo`](https://turborepo.dev/docs/getting-started/installation#global-installation), use your package manager -npx turbo login -yarn exec turbo login -pnpm exec turbo login -``` - -This will authenticate the Turborepo CLI with your [Vercel account](https://vercel.com/docs/concepts/personal-accounts/overview). - -Next, you can link your Turborepo to your Remote Cache by running the following command from the root of your Turborepo: - -``` -# With [global `turbo`](https://turborepo.dev/docs/getting-started/installation#global-installation) installed (recommended) -turbo link - -# Without [global `turbo`](https://turborepo.dev/docs/getting-started/installation#global-installation), use your package manager -npx turbo link -yarn exec turbo link -pnpm exec turbo link -``` - -## Useful Links - -Learn more about the power of Turborepo: - -- [Tasks](https://turborepo.dev/docs/crafting-your-repository/running-tasks) -- [Caching](https://turborepo.dev/docs/crafting-your-repository/caching) -- [Remote Caching](https://turborepo.dev/docs/core-concepts/remote-caching) -- [Filtering](https://turborepo.dev/docs/crafting-your-repository/running-tasks#using-filters) -- [Configuration Options](https://turborepo.dev/docs/reference/configuration) -- [CLI Usage](https://turborepo.dev/docs/reference/command-line-reference) +| 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 | diff --git a/apps/editor/components/ui/item-catalog/item-catalog.tsx b/apps/editor/components/ui/item-catalog/item-catalog.tsx index 0380e6c2..e6c00856 100644 --- a/apps/editor/components/ui/item-catalog/item-catalog.tsx +++ b/apps/editor/components/ui/item-catalog/item-catalog.tsx @@ -12,6 +12,7 @@ import { cn } from "@/lib/utils"; import useEditor, { CatalogCategory } from "@/store/use-editor"; import { CATALOG_ITEMS } from "./catalog-items"; import { AssetInput } from "@pascal-app/core"; +import { resolveCdnUrl } from "@pascal-app/viewer"; export function ItemCatalog({ category }: { category: CatalogCategory }) { const selectedItem = useEditor((state) => state.selectedItem); @@ -62,7 +63,7 @@ export function ItemCatalog({ category }: { category: CatalogCategory }) { alt={item.name} className="rounded-lg object-cover" fill - src={item.thumbnail} + src={resolveCdnUrl(item.thumbnail) || ''} /> {attachmentIcon && (
diff --git a/apps/editor/next.config.ts b/apps/editor/next.config.ts index 70f4f47b..eedfce26 100644 --- a/apps/editor/next.config.ts +++ b/apps/editor/next.config.ts @@ -1,5 +1,6 @@ -/** @type {import('next').NextConfig} */ -const nextConfig = { +import type { NextConfig } from 'next' + +const nextConfig: NextConfig = { transpilePackages: ['three', '@pascal-app/viewer', '@pascal-app/core'], } diff --git a/apps/editor/tsconfig.json b/apps/editor/tsconfig.json index 84e20cd1..13904941 100644 --- a/apps/editor/tsconfig.json +++ b/apps/editor/tsconfig.json @@ -17,5 +17,9 @@ "next.config.js", ".next/types/**/*.ts" ], - "exclude": ["node_modules"] + "exclude": ["node_modules"], + "references": [ + { "path": "../../packages/core" }, + { "path": "../../packages/viewer" } + ] } diff --git a/bun.lock b/bun.lock index 51241704..d9c9ff13 100644 --- a/bun.lock +++ b/bun.lock @@ -1,6 +1,5 @@ { "lockfileVersion": 1, - "configVersion": 0, "workspaces": { "": { "name": "editor", @@ -63,14 +62,16 @@ }, "packages/core": { "name": "@pascal-app/core", - "version": "0.0.1", + "version": "0.1.3", "dependencies": { "dedent": "^1.7.1", "idb-keyval": "^6.2.2", "mitt": "^3.0.1", "nanoid": "^5.1.6", + "three-bvh-csg": "^0.0.17", "zod": "^4.3.5", "zundo": "^2.3.0", + "zustand": "^5", }, "devDependencies": { "@repo/typescript-config": "*", @@ -81,6 +82,7 @@ "peerDependencies": { "@react-three/drei": "^10", "@react-three/fiber": "^9", + "react": "^18 || ^19", "three": "^0.182", }, }, @@ -124,7 +126,10 @@ }, "packages/viewer": { "name": "@pascal-app/viewer", - "version": "0.0.1", + "version": "0.1.3", + "dependencies": { + "zustand": "^5", + }, "devDependencies": { "@repo/typescript-config": "*", "@types/react": "^19.2.2", @@ -132,9 +137,10 @@ "typescript": "5.9.2", }, "peerDependencies": { - "@pascal-app/core": "*", + "@pascal-app/core": "^0.1.3", "@react-three/drei": "^10", "@react-three/fiber": "^9", + "react": "^18 || ^19", "three": "^0.182", }, }, diff --git a/packages/core/README.md b/packages/core/README.md new file mode 100644 index 00000000..3def8fe1 --- /dev/null +++ b/packages/core/README.md @@ -0,0 +1,76 @@ +# @pascal-app/core + +Core library for Pascal 3D building editor. + +## Installation + +```bash +npm install @pascal-app/core +``` + +## Peer Dependencies + +```bash +npm install react three @react-three/fiber @react-three/drei +``` + +## What's Included + +- **Node Schemas** - Zod schemas for all building primitives (walls, slabs, items, etc.) +- **Scene State** - Zustand store with IndexedDB persistence and undo/redo +- **Systems** - Geometry generation for walls, floors, ceilings, roofs +- **Scene Registry** - Fast lookup from node IDs to Three.js objects +- **Spatial Grid** - Collision detection and placement validation +- **Event Bus** - Typed event emitter for inter-component communication +- **Asset Storage** - IndexedDB-based file storage for user-uploaded assets + +## Usage + +```typescript +import { useScene, WallNode, ItemNode } from '@pascal-app/core' + +// Create a wall +const wall = WallNode.parse({ + points: [[0, 0], [5, 0]], + height: 3, + thickness: 0.2, +}) + +useScene.getState().createNode(wall, parentLevelId) + +// Subscribe to scene changes +function MyComponent() { + const nodes = useScene((state) => state.nodes) + const walls = Object.values(nodes).filter(n => n.type === 'wall') + + return
Total walls: {walls.length}
+} +``` + +## Node Types + +- `SiteNode` - Root container +- `BuildingNode` - Building within a site +- `LevelNode` - Floor level +- `WallNode` - Vertical wall with optional openings +- `SlabNode` - Floor slab +- `CeilingNode` - Ceiling surface +- `RoofNode` - Roof geometry +- `ZoneNode` - Spatial zone/room +- `ItemNode` - Furniture, fixtures, appliances +- `ScanNode` - 3D scan reference +- `GuideNode` - 2D guide image reference + +## Systems + +Systems process dirty nodes each frame to update geometry: + +- `WallSystem` - Wall geometry with mitering and CSG cutouts +- `SlabSystem` - Floor polygon generation +- `CeilingSystem` - Ceiling geometry +- `RoofSystem` - Roof generation +- `ItemSystem` - Item positioning on walls/ceilings/floors + +## License + +MIT diff --git a/packages/core/package.json b/packages/core/package.json index 4a2b8a37..58b19da5 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,33 +1,60 @@ { "name": "@pascal-app/core", - "version": "0.0.1", - "private": true, + "version": "0.1.10", + "description": "Core library for Pascal 3D building editor", "type": "module", - "main": "./src/index.ts", - "types": "./src/index.ts", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", "exports": { ".": { - "types": "./src/index.ts", - "default": "./src/index.ts" + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" } }, + "files": [ + "dist", + "README.md" + ], + "scripts": { + "build": "tsc --build", + "dev": "tsc --build --watch", + "prepublishOnly": "npm run build" + }, "peerDependencies": { "@react-three/drei": "^10", "@react-three/fiber": "^9", + "react": "^18 || ^19", "three": "^0.182" }, + "dependencies": { + "dedent": "^1.7.1", + "idb-keyval": "^6.2.2", + "mitt": "^3.0.1", + "nanoid": "^5.1.6", + "three-bvh-csg": "^0.0.17", + "zod": "^4.3.5", + "zundo": "^2.3.0", + "zustand": "^5" + }, "devDependencies": { "@repo/typescript-config": "*", "@types/react": "^19.2.2", "typescript": "5.9.2", "@types/three": "^0.182.0" }, - "dependencies": { - "dedent": "^1.7.1", - "idb-keyval": "^6.2.2", - "mitt": "^3.0.1", - "nanoid": "^5.1.6", - "zod": "^4.3.5", - "zundo": "^2.3.0" - } + "keywords": [ + "3d", + "building", + "editor", + "architecture", + "webgpu", + "three.js" + ], + "repository": { + "type": "git", + "url": "https://github.com/your-username/pascal-editor.git", + "directory": "packages/core" + }, + "license": "MIT" } diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json index d2c35871..71ee96a0 100644 --- a/packages/core/tsconfig.json +++ b/packages/core/tsconfig.json @@ -2,8 +2,10 @@ "extends": "@repo/typescript-config/react-library.json", "compilerOptions": { "outDir": "dist", - "rootDir": "src" + "rootDir": "src", + "composite": true, + "incremental": true }, - "include": ["src", "../viewer/src/systems/level"], + "include": ["src"], "exclude": ["node_modules", "dist"] } diff --git a/packages/typescript-config/nextjs.json b/packages/typescript-config/nextjs.json index e6defa48..964f1909 100644 --- a/packages/typescript-config/nextjs.json +++ b/packages/typescript-config/nextjs.json @@ -7,6 +7,8 @@ "moduleResolution": "Bundler", "allowJs": true, "jsx": "preserve", - "noEmit": true + "noEmit": true, + "declaration": false, + "declarationMap": false } } diff --git a/packages/viewer/README.md b/packages/viewer/README.md new file mode 100644 index 00000000..6e768846 --- /dev/null +++ b/packages/viewer/README.md @@ -0,0 +1,103 @@ +# @pascal-app/viewer + +3D viewer component for Pascal building editor. + +## Installation + +```bash +npm install @pascal-app/viewer @pascal-app/core +``` + +## Peer Dependencies + +```bash +npm install react three @react-three/fiber @react-three/drei +``` + +## What's Included + +- **Viewer Component** - WebGPU-powered 3D viewer with camera controls +- **Node Renderers** - React Three Fiber components for all node types +- **Post-Processing** - SSGI (ambient occlusion + global illumination), TRAA (anti-aliasing), outline effects +- **Level System** - Level visibility and positioning (stacked/exploded/solo modes) +- **Wall Cutout System** - Dynamic wall hiding based on camera position +- **Asset URL Helpers** - CDN URL resolution for models and textures + +## Usage + +```typescript +import { Viewer, useViewer } from '@pascal-app/viewer' +import { useScene } from '@pascal-app/core' + +function App() { + return ( +
+ +
+ ) +} +``` + +## Custom Camera Controls + +```typescript +import { Viewer } from '@pascal-app/viewer' +import { CameraControls } from '@react-three/drei' + +function App() { + return ( + + + + ) +} +``` + +## Viewer State + +```typescript +import { useViewer } from '@pascal-app/viewer' + +function ViewerControls() { + const levelMode = useViewer(s => s.levelMode) + const setLevelMode = useViewer(s => s.setLevelMode) + const wallMode = useViewer(s => s.wallMode) + const setWallMode = useViewer(s => s.setWallMode) + + return ( +
+ + + + +
+ ) +} +``` + +## Asset CDN Helpers + +```typescript +import { resolveCdnUrl, ASSETS_CDN_URL } from '@pascal-app/viewer' + +// Resolves relative paths to CDN URLs +const url = resolveCdnUrl('/items/chair/model.glb') +// → 'https://pascal-cdn.wawasensei.dev/items/chair/model.glb' + +// Handles external URLs and asset:// protocol +const externalUrl = resolveCdnUrl('https://example.com/model.glb') +// → 'https://example.com/model.glb' (unchanged) +``` + +## Features + +- **WebGPU Rendering** - Hardware-accelerated rendering via Three.js WebGPU +- **Post-Processing** - SSGI for realistic lighting, outline effects for selection +- **Level Modes** - Stacked, exploded, or solo level display +- **Wall Cutaway** - Automatic wall hiding for interior views +- **Camera Modes** - Perspective and orthographic projection +- **Scan/Guide Support** - 3D scans and 2D guide images + +## License + +MIT diff --git a/packages/viewer/package.json b/packages/viewer/package.json index f4594b34..774f8ef6 100644 --- a/packages/viewer/package.json +++ b/packages/viewer/package.json @@ -1,26 +1,56 @@ { "name": "@pascal-app/viewer", - "version": "0.0.1", - "private": true, + "version": "0.1.10", + "description": "3D viewer component for Pascal building editor", "type": "module", - "main": "./src/index.ts", - "types": "./src/index.ts", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", "exports": { ".": { - "types": "./src/index.ts", - "default": "./src/index.ts" + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" } }, + "files": [ + "dist", + "README.md" + ], + "scripts": { + "build": "tsc --build", + "dev": "tsc --build --watch", + "prepublishOnly": "npm run build" + }, "peerDependencies": { - "@pascal-app/core": "*", + "@pascal-app/core": "^0.1.4", "@react-three/drei": "^10", "@react-three/fiber": "^9", + "react": "^18 || ^19", "three": "^0.182" }, + "dependencies": { + "zustand": "^5" + }, "devDependencies": { "@repo/typescript-config": "*", "@types/react": "^19.2.2", "typescript": "5.9.2", "@types/three": "^0.182.0" - } + }, + "keywords": [ + "3d", + "building", + "editor", + "viewer", + "architecture", + "webgpu", + "three.js", + "react-three-fiber" + ], + "repository": { + "type": "git", + "url": "https://github.com/your-username/pascal-editor.git", + "directory": "packages/viewer" + }, + "license": "MIT" } diff --git a/packages/viewer/src/components/renderers/item/item-renderer.tsx b/packages/viewer/src/components/renderers/item/item-renderer.tsx index f247dd29..34bc1d9f 100644 --- a/packages/viewer/src/components/renderers/item/item-renderer.tsx +++ b/packages/viewer/src/components/renderers/item/item-renderer.tsx @@ -4,6 +4,7 @@ import { useGLTF } from '@react-three/drei/core/Gltf' import { Suspense, useEffect, useMemo, useRef } from 'react' import type { Group, Material, Mesh } from 'three' import { DoubleSide, MeshStandardNodeMaterial } from 'three/webgpu' +import { resolveCdnUrl } from '../../../lib/asset-url' import { useNodeEvents } from '../../../hooks/use-node-events' // Shared materials to avoid creating new instances for every mesh @@ -46,7 +47,7 @@ export const ItemRenderer = ({ node }: { node: ItemNode }) => { } const ModelRenderer = ({ node }: { node: ItemNode }) => { - const { scene, nodes } = useGLTF(node.asset.src) + const { scene, nodes } = useGLTF(resolveCdnUrl(node.asset.src) || '') if (nodes.cutout) { nodes.cutout.visible = false @@ -64,19 +65,19 @@ const ModelRenderer = ({ node }: { node: ItemNode }) => { if ((child as Mesh).isMesh) { const mesh = child as Mesh if (mesh.name === 'cutout') { - child.visible = false; + child.visible = false return } - let hasGlass = false; + let hasGlass = false // Handle both single material and material array cases if (Array.isArray(mesh.material)) { mesh.material = mesh.material.map((mat) => getMaterialForOriginal(mat)) - hasGlass = mesh.material.some(mat => mat.name === 'glass'); + hasGlass = mesh.material.some((mat) => mat.name === 'glass') } else { mesh.material = getMaterialForOriginal(mesh.material) - hasGlass = mesh.material.name === 'glass'; + hasGlass = mesh.material.name === 'glass' } mesh.castShadow = !hasGlass mesh.receiveShadow = !hasGlass diff --git a/packages/viewer/src/components/renderers/scan/scan-renderer.tsx b/packages/viewer/src/components/renderers/scan/scan-renderer.tsx index e07684a4..67e593a7 100644 --- a/packages/viewer/src/components/renderers/scan/scan-renderer.tsx +++ b/packages/viewer/src/components/renderers/scan/scan-renderer.tsx @@ -27,7 +27,8 @@ export const ScanRenderer = ({ node }: { node: ScanNode }) => { } const ScanModel = ({ url, opacity }: { url: string; opacity: number }) => { - const { scene } = useGLTFKTX2(url) + const gltf = useGLTFKTX2(url) as any + const scene = gltf.scene useMemo(() => { const normalizedOpacity = opacity / 100 @@ -44,7 +45,7 @@ const ScanModel = ({ url, opacity }: { url: string; opacity: number }) => { material.needsUpdate = true } - scene.traverse((child) => { + scene.traverse((child: any) => { if ((child as Mesh).isMesh) { const mesh = child as Mesh diff --git a/packages/viewer/src/hooks/use-gltf-ktx2.tsx b/packages/viewer/src/hooks/use-gltf-ktx2.tsx index 7b4bc4de..70d93cd3 100644 --- a/packages/viewer/src/hooks/use-gltf-ktx2.tsx +++ b/packages/viewer/src/hooks/use-gltf-ktx2.tsx @@ -6,7 +6,7 @@ import { MeshoptDecoder } from "three/examples/jsm/libs/meshopt_decoder.module.j const ktx2LoaderInstance = new KTX2Loader() ktx2LoaderInstance.setTranscoderPath('https://cdn.jsdelivr.net/gh/pmndrs/drei-assets@master/basis/') -const useGLTFKTX2 = (path: string) => { +const useGLTFKTX2 = (path: string): ReturnType => { const gl = useThree((state) => state.gl) return useGLTF(path, true, true, (loader) => { diff --git a/packages/viewer/src/index.ts b/packages/viewer/src/index.ts index 31d4fd9e..21aa3ebd 100644 --- a/packages/viewer/src/index.ts +++ b/packages/viewer/src/index.ts @@ -1,3 +1,4 @@ export { default as Viewer } from './components/viewer' export { useGridEvents } from './hooks/use-grid-events' export { default as useViewer } from './store/use-viewer' +export { ASSETS_CDN_URL, resolveAssetUrl, resolveCdnUrl } from './lib/asset-url' \ No newline at end of file diff --git a/packages/viewer/src/lib/asset-url.ts b/packages/viewer/src/lib/asset-url.ts new file mode 100644 index 00000000..242be239 --- /dev/null +++ b/packages/viewer/src/lib/asset-url.ts @@ -0,0 +1,51 @@ +import { loadAssetUrl } from '@pascal-app/core' + +export const ASSETS_CDN_URL = 'https://editor.pascal.app' + +/** + * Resolves an asset URL to the appropriate format: + * - If URL starts with http:// or https://, return as-is (external URL) + * - If URL starts with asset://, resolve from IndexedDB storage + * - If URL starts with /, prepend CDN URL (absolute path) + * - Otherwise, prepend CDN URL (relative path) + */ +export async function resolveAssetUrl(url: string | undefined | null): Promise { + if (!url) return null + + // External URL - use as-is + if (url.startsWith('http://') || url.startsWith('https://')) { + return url + } + + // IndexedDB asset - resolve from storage + if (url.startsWith('asset://')) { + return loadAssetUrl(url) + } + + // Absolute or relative path - prepend CDN URL + const normalizedPath = url.startsWith('/') ? url : `/${url}` + return `${ASSETS_CDN_URL}${normalizedPath}` +} + +/** + * Synchronous version for URLs that don't need IndexedDB resolution + * Only use this if you're sure the URL is not an asset:// URL + */ +export function resolveCdnUrl(url: string | undefined | null): string | null { + if (!url) return null + + // External URL - use as-is + if (url.startsWith('http://') || url.startsWith('https://')) { + return url + } + + // Don't use this for asset:// URLs - use resolveAssetUrl instead + if (url.startsWith('asset://')) { + console.warn('Use resolveAssetUrl() for asset:// URLs, not resolveCdnUrl()') + return null + } + + // Absolute or relative path - prepend CDN URL + const normalizedPath = url.startsWith('/') ? url : `/${url}` + return `${ASSETS_CDN_URL}${normalizedPath}` +} diff --git a/packages/viewer/src/store/use-viewer.d.ts b/packages/viewer/src/store/use-viewer.d.ts new file mode 100644 index 00000000..736f9544 --- /dev/null +++ b/packages/viewer/src/store/use-viewer.d.ts @@ -0,0 +1,35 @@ +import type { AnyNode, BaseNode, BuildingNode, LevelNode, ZoneNode } from "@pascal-app/core"; +import type { Object3D } from "three"; +type SelectionPath = { + buildingId: BuildingNode["id"] | null; + levelId: LevelNode["id"] | null; + zoneId: ZoneNode["id"] | null; + selectedIds: BaseNode["id"][]; +}; +type Outliner = { + selectedObjects: Object3D[]; + hoveredObjects: Object3D[]; +}; +type ViewerState = { + selection: SelectionPath; + hoveredId: AnyNode['id'] | ZoneNode['id'] | null; + setHoveredId: (id: AnyNode['id'] | ZoneNode['id'] | null) => void; + cameraMode: 'perspective' | 'orthographic'; + setCameraMode: (mode: 'perspective' | 'orthographic') => void; + levelMode: 'stacked' | 'exploded' | 'solo' | 'manual'; + setLevelMode: (mode: 'stacked' | 'exploded' | 'solo' | 'manual') => void; + wallMode: 'up' | 'cutaway' | 'down'; + setWallMode: (mode: 'up' | 'cutaway' | 'down') => void; + showScans: boolean; + setShowScans: (show: boolean) => void; + showGuides: boolean; + setShowGuides: (show: boolean) => void; + setSelection: (updates: Partial) => void; + resetSelection: () => void; + outliner: Outliner; + exportScene: (() => Promise) | null; + setExportScene: (fn: (() => Promise) | null) => void; +}; +declare const useViewer: import("zustand").UseBoundStore>; +export default useViewer; +//# sourceMappingURL=use-viewer.d.ts.map \ No newline at end of file diff --git a/packages/viewer/src/store/use-viewer.d.ts.map b/packages/viewer/src/store/use-viewer.d.ts.map new file mode 100644 index 00000000..7a03414b --- /dev/null +++ b/packages/viewer/src/store/use-viewer.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"use-viewer.d.ts","sourceRoot":"","sources":["use-viewer.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EACV,OAAO,EACP,QAAQ,EACR,YAAY,EACZ,SAAS,EACT,QAAQ,EACT,MAAM,kBAAkB,CAAC;AAC1B,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAC;AAItC,KAAK,aAAa,GAAG;IACnB,UAAU,EAAE,YAAY,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC;IACtC,OAAO,EAAE,SAAS,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC;IAChC,MAAM,EAAE,QAAQ,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC;IAC9B,WAAW,EAAE,QAAQ,CAAC,IAAI,CAAC,EAAE,CAAC;CAC/B,CAAC;AAEF,KAAK,QAAQ,GAAG;IACd,eAAe,EAAE,QAAQ,EAAE,CAAC;IAC5B,cAAc,EAAE,QAAQ,EAAE,CAAC;CAC5B,CAAC;AAEF,KAAK,WAAW,GAAG;IACjB,SAAS,EAAE,aAAa,CAAA;IACxB,SAAS,EAAE,OAAO,CAAC,IAAI,CAAC,GAAG,QAAQ,CAAC,IAAI,CAAC,GAAG,IAAI,CAAA;IAChD,YAAY,EAAE,CAAC,EAAE,EAAE,OAAO,CAAC,IAAI,CAAC,GAAG,QAAQ,CAAC,IAAI,CAAC,GAAG,IAAI,KAAK,IAAI,CAAA;IAEjE,UAAU,EAAE,aAAa,GAAG,cAAc,CAAA;IAC1C,aAAa,EAAE,CAAC,IAAI,EAAE,aAAa,GAAG,cAAc,KAAK,IAAI,CAAA;IAE7D,SAAS,EAAE,SAAS,GAAG,UAAU,GAAG,MAAM,GAAG,QAAQ,CAAA;IACrD,YAAY,EAAE,CAAC,IAAI,EAAE,SAAS,GAAG,UAAU,GAAG,MAAM,GAAG,QAAQ,KAAK,IAAI,CAAA;IAExE,QAAQ,EAAE,IAAI,GAAG,SAAS,GAAG,MAAM,CAAA;IACnC,WAAW,EAAE,CAAC,IAAI,EAAE,IAAI,GAAG,SAAS,GAAG,MAAM,KAAK,IAAI,CAAA;IAEtD,SAAS,EAAE,OAAO,CAAA;IAClB,YAAY,EAAE,CAAC,IAAI,EAAE,OAAO,KAAK,IAAI,CAAA;IAErC,UAAU,EAAE,OAAO,CAAA;IACnB,aAAa,EAAE,CAAC,IAAI,EAAE,OAAO,KAAK,IAAI,CAAA;IAGtC,YAAY,EAAE,CAAC,OAAO,EAAE,OAAO,CAAC,aAAa,CAAC,KAAK,IAAI,CAAA;IACvD,cAAc,EAAE,MAAM,IAAI,CAAA;IAE1B,QAAQ,EAAE,QAAQ,CAAA;IAGlB,WAAW,EAAE,CAAC,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC,GAAG,IAAI,CAAA;IACzC,cAAc,EAAE,CAAC,EAAE,EAAE,CAAC,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC,GAAG,IAAI,KAAK,IAAI,CAAA;CAC3D,CAAA;AAED,QAAA,MAAM,SAAS,0EAqDZ,CAAC;AAEJ,eAAe,SAAS,CAAC"} \ No newline at end of file diff --git a/packages/viewer/src/systems/level/level-system.d.ts b/packages/viewer/src/systems/level/level-system.d.ts new file mode 100644 index 00000000..5f5df3f9 --- /dev/null +++ b/packages/viewer/src/systems/level/level-system.d.ts @@ -0,0 +1,2 @@ +export declare const LevelSystem: () => null; +//# sourceMappingURL=level-system.d.ts.map \ No newline at end of file diff --git a/packages/viewer/src/systems/level/level-system.d.ts.map b/packages/viewer/src/systems/level/level-system.d.ts.map new file mode 100644 index 00000000..e98f88d6 --- /dev/null +++ b/packages/viewer/src/systems/level/level-system.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"level-system.d.ts","sourceRoot":"","sources":["level-system.tsx"],"names":[],"mappings":"AAQA,eAAO,MAAM,WAAW,YAkBvB,CAAA"} \ No newline at end of file diff --git a/packages/viewer/tsconfig.json b/packages/viewer/tsconfig.json index a47d5777..881b384e 100644 --- a/packages/viewer/tsconfig.json +++ b/packages/viewer/tsconfig.json @@ -2,8 +2,13 @@ "extends": "@repo/typescript-config/react-library.json", "compilerOptions": { "outDir": "dist", - "rootDir": "src" + "rootDir": "src", + "composite": true, + "incremental": true }, "include": ["src"], - "exclude": ["node_modules", "dist"] + "exclude": ["node_modules", "dist"], + "references": [ + { "path": "../core" } + ] } diff --git a/turbo.json b/turbo.json index 62383cde..8f1ce2f4 100644 --- a/turbo.json +++ b/turbo.json @@ -5,7 +5,7 @@ "build": { "dependsOn": ["^build"], "inputs": ["$TURBO_DEFAULT$", ".env*"], - "outputs": [".next/**", "!.next/cache/**"] + "outputs": [".next/**", "!.next/cache/**", "dist/**"] }, "lint": { "dependsOn": ["^lint"] @@ -14,6 +14,7 @@ "dependsOn": ["^check-types"] }, "dev": { + "dependsOn": ["^build"], "cache": false, "persistent": true }