Merge pull request #85 from pascalorg/chore/npm-packages

Chore/npm packages
This commit is contained in:
Wassim SAMAD
2026-02-05 10:07:48 +09:00
committed by GitHub
23 changed files with 777 additions and 163 deletions
+1
View File
@@ -26,6 +26,7 @@ coverage
out/
build
dist
*.tsbuildinfo
# Debug
+381 -119
View File
@@ -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<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 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 |
@@ -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 && (
<div className="absolute right-0.5 bottom-0.5 flex h-4 w-4 items-center justify-center rounded bg-black/60">
+3 -2
View File
@@ -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'],
}
+5 -1
View File
@@ -17,5 +17,9 @@
"next.config.js",
".next/types/**/*.ts"
],
"exclude": ["node_modules"]
"exclude": ["node_modules"],
"references": [
{ "path": "../../packages/core" },
{ "path": "../../packages/viewer" }
]
}
+10 -4
View File
@@ -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",
},
},
+76
View File
@@ -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 <div>Total walls: {walls.length}</div>
}
```
## 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
+41 -14
View File
@@ -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"
}
+4 -2
View File
@@ -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"]
}
+3 -1
View File
@@ -7,6 +7,8 @@
"moduleResolution": "Bundler",
"allowJs": true,
"jsx": "preserve",
"noEmit": true
"noEmit": true,
"declaration": false,
"declarationMap": false
}
}
+103
View File
@@ -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 (
<div style={{ width: '100vw', height: '100vh' }}>
<Viewer />
</div>
)
}
```
## Custom Camera Controls
```typescript
import { Viewer } from '@pascal-app/viewer'
import { CameraControls } from '@react-three/drei'
function App() {
return (
<Viewer selectionManager="custom">
<CameraControls />
</Viewer>
)
}
```
## 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 (
<div>
<button onClick={() => setLevelMode('stacked')}>Stacked</button>
<button onClick={() => setLevelMode('exploded')}>Exploded</button>
<button onClick={() => setWallMode('cutaway')}>Cutaway</button>
<button onClick={() => setWallMode('up')}>Full Height</button>
</div>
)
}
```
## 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
+38 -8
View File
@@ -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"
}
@@ -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
@@ -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
+1 -1
View File
@@ -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<typeof useGLTF> => {
const gl = useThree((state) => state.gl)
return useGLTF(path, true, true, (loader) => {
+1
View File
@@ -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'
+51
View File
@@ -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<string | null> {
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}`
}
+35
View File
@@ -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<SelectionPath>) => void;
resetSelection: () => void;
outliner: Outliner;
exportScene: (() => Promise<void>) | null;
setExportScene: (fn: (() => Promise<void>) | null) => void;
};
declare const useViewer: import("zustand").UseBoundStore<import("zustand").StoreApi<ViewerState>>;
export default useViewer;
//# sourceMappingURL=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"}
+2
View File
@@ -0,0 +1,2 @@
export declare const LevelSystem: () => null;
//# sourceMappingURL=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"}
+7 -2
View File
@@ -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" }
]
}
+2 -1
View File
@@ -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
}