remove community
This commit is contained in:
@@ -1,142 +0,0 @@
|
||||
# @pascal-app/auth
|
||||
|
||||
Authentication package for Pascal Editor using Better Auth.
|
||||
|
||||
## Features
|
||||
|
||||
- **Magic Link Authentication** - Passwordless email-based authentication
|
||||
- **Session Management** - Secure cookie-based sessions
|
||||
- **Supabase Integration** - Uses Supabase as the database adapter
|
||||
- **Type-safe** - Full TypeScript support with type inference
|
||||
|
||||
## Setup
|
||||
|
||||
### 1. Configure environment variables
|
||||
|
||||
Add these to `apps/editor/.env.local`:
|
||||
|
||||
```bash
|
||||
# Better Auth
|
||||
BETTER_AUTH_SECRET=<generate_with_openssl_rand_base64_32>
|
||||
BETTER_AUTH_URL=http://localhost:3000
|
||||
```
|
||||
|
||||
Generate a secret for `BETTER_AUTH_SECRET`:
|
||||
|
||||
```bash
|
||||
openssl rand -base64 32
|
||||
```
|
||||
|
||||
### 2. Ensure database is running
|
||||
|
||||
Make sure you have Supabase running with the auth tables created. See `@pascal-app/db` package for setup.
|
||||
|
||||
## Usage
|
||||
|
||||
### Server-side (API routes, server actions)
|
||||
|
||||
```typescript
|
||||
import { auth } from '@pascal-app/auth/server'
|
||||
|
||||
// Get session in server component or action
|
||||
const session = await auth.api.getSession({ headers: request.headers })
|
||||
|
||||
if (!session) {
|
||||
return { error: 'Unauthorized' }
|
||||
}
|
||||
|
||||
// Access user data
|
||||
const userId = session.user.id
|
||||
const email = session.user.email
|
||||
```
|
||||
|
||||
### Client-side (React components)
|
||||
|
||||
```typescript
|
||||
'use client'
|
||||
|
||||
import { authClient } from '@pascal-app/auth/client'
|
||||
|
||||
function SignInButton() {
|
||||
const { signIn } = authClient
|
||||
|
||||
const handleSignIn = async (email: string) => {
|
||||
await signIn.magicLink({
|
||||
email,
|
||||
callbackURL: '/dashboard',
|
||||
})
|
||||
}
|
||||
|
||||
return <button onClick={() => handleSignIn('user@example.com')}>Sign In</button>
|
||||
}
|
||||
```
|
||||
|
||||
### Using the auth hook
|
||||
|
||||
```typescript
|
||||
'use client'
|
||||
|
||||
import { authClient } from '@pascal-app/auth/client'
|
||||
|
||||
function Profile() {
|
||||
const { data: session, isPending } = authClient.useSession()
|
||||
|
||||
if (isPending) return <div>Loading...</div>
|
||||
if (!session) return <div>Not signed in</div>
|
||||
|
||||
return <div>Signed in as {session.user.email}</div>
|
||||
}
|
||||
```
|
||||
|
||||
## API Routes
|
||||
|
||||
The auth package requires an API route handler in your Next.js app:
|
||||
|
||||
```typescript
|
||||
// app/api/auth/[...all]/route.ts
|
||||
import { auth } from '@pascal-app/auth/server'
|
||||
import { toNextJsHandler } from 'better-auth/next-js'
|
||||
|
||||
export const { GET, POST } = toNextJsHandler(auth)
|
||||
```
|
||||
|
||||
This handles all Better Auth endpoints:
|
||||
- `/api/auth/sign-in/magic-link` - Send magic link
|
||||
- `/api/auth/sign-in/magic-link/verify` - Verify magic link
|
||||
- `/api/auth/sign-out` - Sign out
|
||||
- `/api/auth/session` - Get session
|
||||
- And more...
|
||||
|
||||
## Email Configuration
|
||||
|
||||
By default, magic links are logged to the console. To send actual emails, you'll need to configure an email provider in `packages/auth/src/server.ts`:
|
||||
|
||||
```typescript
|
||||
magicLink({
|
||||
sendMagicLink: async ({ email, url }) => {
|
||||
// Use Resend, SendGrid, or your preferred email service
|
||||
await sendEmail({
|
||||
to: email,
|
||||
subject: 'Sign in to Pascal Editor',
|
||||
html: `Click here to sign in: <a href="${url}">${url}</a>`,
|
||||
})
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Database Schema
|
||||
|
||||
The auth package requires these tables (created by `@pascal-app/db` migrations):
|
||||
|
||||
- `users` - User accounts
|
||||
- `sessions` - Active sessions
|
||||
- `accounts` - OAuth provider accounts (for future use)
|
||||
- `verification_tokens` - Magic link tokens
|
||||
|
||||
## Security
|
||||
|
||||
- Session cookies are httpOnly and secure (in production)
|
||||
- Sessions expire after 7 days
|
||||
- Session cache is enabled for 5 minutes to reduce database queries
|
||||
- Magic link tokens expire after 15 minutes
|
||||
- All sensitive operations require valid session tokens
|
||||
@@ -1,24 +0,0 @@
|
||||
{
|
||||
"name": "@pascal-app/auth",
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
"./server": {
|
||||
"types": "./src/server.ts",
|
||||
"default": "./src/server.ts"
|
||||
},
|
||||
"./client": {
|
||||
"types": "./src/client.ts",
|
||||
"default": "./src/client.ts"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@pascal-app/db": "*",
|
||||
"better-auth": "^1.5.2",
|
||||
"resend": "^4.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@repo/typescript-config": "*",
|
||||
"typescript": "5.9.3"
|
||||
}
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
import { lastLoginMethodClient, magicLinkClient } from 'better-auth/client/plugins'
|
||||
import { createAuthClient } from 'better-auth/react'
|
||||
|
||||
/**
|
||||
* Get the auth base URL
|
||||
* In development: use the editor URL (localhost:3000)
|
||||
* In production: use the same origin
|
||||
*/
|
||||
function getAuthURL(): string {
|
||||
if (typeof window !== 'undefined') {
|
||||
return window.location.origin
|
||||
}
|
||||
|
||||
// SSR fallback - detect environment from Vercel variables
|
||||
const isDevelopment =
|
||||
process.env.NODE_ENV === 'development' ||
|
||||
process.env.NEXT_PUBLIC_VERCEL_ENV === 'development'
|
||||
const isPreview = process.env.NEXT_PUBLIC_VERCEL_ENV === 'preview'
|
||||
const isProduction =
|
||||
process.env.NODE_ENV === 'production' || process.env.NEXT_PUBLIC_VERCEL_ENV === 'production'
|
||||
|
||||
if (isDevelopment) {
|
||||
return process.env.NEXT_PUBLIC_APP_URL || `http://localhost:${process.env.PORT || 3000}`
|
||||
}
|
||||
|
||||
if (isPreview && process.env.NEXT_PUBLIC_VERCEL_URL) {
|
||||
return `https://${process.env.NEXT_PUBLIC_VERCEL_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')
|
||||
)
|
||||
}
|
||||
|
||||
return 'http://localhost:3000'
|
||||
}
|
||||
|
||||
/**
|
||||
* Auth client instance
|
||||
* Configured for magic link authentication
|
||||
*/
|
||||
export const authClient = createAuthClient({
|
||||
baseURL: getAuthURL(),
|
||||
plugins: [magicLinkClient(), lastLoginMethodClient()],
|
||||
})
|
||||
|
||||
/**
|
||||
* Export types for use in components
|
||||
*/
|
||||
export type AuthState = {
|
||||
user: (typeof authClient)['$Infer']['Session']['user'] | null
|
||||
session: (typeof authClient)['$Infer']['Session']['session'] | null
|
||||
isLoading: boolean
|
||||
}
|
||||
|
||||
export type User = NonNullable<AuthState['user']>
|
||||
export type Session = NonNullable<AuthState['session']>
|
||||
@@ -1,99 +0,0 @@
|
||||
import type { Database } from '@pascal-app/db'
|
||||
import { schema } from '@pascal-app/db'
|
||||
import type { BetterAuthOptions } from 'better-auth'
|
||||
import { betterAuth } from 'better-auth'
|
||||
import { drizzleAdapter } from 'better-auth/adapters/drizzle'
|
||||
import { lastLoginMethod, magicLink } from 'better-auth/plugins'
|
||||
|
||||
export interface SendMagicLinkParams {
|
||||
email: string
|
||||
url: string
|
||||
token: string
|
||||
}
|
||||
|
||||
export interface AuthConfig {
|
||||
db: Database
|
||||
appName: string
|
||||
baseURL: string
|
||||
secret: string
|
||||
/** Google OAuth client ID */
|
||||
googleClientId?: string
|
||||
/** Google OAuth client secret */
|
||||
googleClientSecret?: string
|
||||
/** Callback to send magic link emails */
|
||||
sendMagicLink?: (params: SendMagicLinkParams) => Promise<void>
|
||||
/** Additional plugins to add (e.g., nextCookies for web) */
|
||||
additionalPlugins?: BetterAuthOptions['plugins']
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a Better Auth instance with full configuration including:
|
||||
* - Magic link authentication
|
||||
* - Custom session with activePropertyId
|
||||
* - Session cookie caching
|
||||
*/
|
||||
export function createAuth(config: AuthConfig) {
|
||||
return betterAuth({
|
||||
appName: config.appName,
|
||||
baseURL: config.baseURL,
|
||||
secret: config.secret,
|
||||
basePath: '/api/auth',
|
||||
database: drizzleAdapter(config.db, {
|
||||
provider: 'pg',
|
||||
usePlural: true,
|
||||
schema,
|
||||
}),
|
||||
advanced: {
|
||||
database: {
|
||||
generateId: false, // Use our prefixed nanoid IDs from schema
|
||||
},
|
||||
},
|
||||
session: {
|
||||
// Session caching to reduce database queries
|
||||
cookieCache: {
|
||||
enabled: true,
|
||||
maxAge: 5 * 60, // Cache duration in seconds (5 minutes)
|
||||
},
|
||||
additionalFields: {
|
||||
// Additional fields for the session table
|
||||
activePropertyId: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
},
|
||||
// Account linking — always enabled so magic link + Google users can share accounts
|
||||
account: {
|
||||
accountLinking: {
|
||||
enabled: true,
|
||||
trustedProviders: ['google', 'email'],
|
||||
},
|
||||
},
|
||||
// Google OAuth provider (only enabled when credentials are provided)
|
||||
...(config.googleClientId &&
|
||||
config.googleClientSecret && {
|
||||
socialProviders: {
|
||||
google: {
|
||||
clientId: config.googleClientId,
|
||||
clientSecret: config.googleClientSecret,
|
||||
},
|
||||
},
|
||||
}),
|
||||
plugins: [
|
||||
...(config.additionalPlugins ?? []),
|
||||
// Track which login method was last used (e.g., "google", "magic-link")
|
||||
lastLoginMethod(),
|
||||
// Magic link authentication
|
||||
...(config.sendMagicLink
|
||||
? [
|
||||
magicLink({
|
||||
sendMagicLink: config.sendMagicLink,
|
||||
expiresIn: 300, // 5 minutes
|
||||
disableSignUp: false, // Allow new users to sign up via magic link
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
export type Auth = ReturnType<typeof createAuth>
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"extends": "@repo/typescript-config/base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
# @pascal-app/db
|
||||
|
||||
Database package for Pascal Editor with Supabase.
|
||||
|
||||
## Setup
|
||||
|
||||
### 1. Install Dependencies
|
||||
|
||||
From the monorepo root:
|
||||
|
||||
```bash
|
||||
bun install
|
||||
```
|
||||
|
||||
This installs Supabase CLI as a dev dependency.
|
||||
|
||||
### 2. Start Supabase locally
|
||||
|
||||
From the monorepo root:
|
||||
|
||||
```bash
|
||||
bun db:start
|
||||
```
|
||||
|
||||
This will start a local Supabase instance with PostgreSQL, PostgREST, and Studio.
|
||||
|
||||
### 3. Check Supabase status
|
||||
|
||||
```bash
|
||||
bun db:status
|
||||
```
|
||||
|
||||
You'll see output like:
|
||||
|
||||
```
|
||||
API URL: http://127.0.0.1:54321
|
||||
DB URL: postgresql://postgres:postgres@127.0.0.1:54322/postgres
|
||||
Studio URL: http://127.0.0.1:54323
|
||||
Anon key: eyJh...
|
||||
Service role key: eyJh...
|
||||
```
|
||||
|
||||
### 4. Configure environment variables
|
||||
|
||||
Add these to `apps/editor/.env.local`:
|
||||
|
||||
```bash
|
||||
# Supabase
|
||||
NEXT_PUBLIC_SUPABASE_URL=http://127.0.0.1:54321
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=<your_anon_key>
|
||||
SUPABASE_SERVICE_ROLE_KEY=<your_service_role_key>
|
||||
|
||||
# Better Auth
|
||||
BETTER_AUTH_SECRET=<generate_with_openssl_rand_base64_32>
|
||||
BETTER_AUTH_URL=http://localhost:3000
|
||||
```
|
||||
|
||||
Generate a secret for `BETTER_AUTH_SECRET`:
|
||||
|
||||
```bash
|
||||
openssl rand -base64 32
|
||||
```
|
||||
|
||||
## Migrations
|
||||
|
||||
Migrations are located in `supabase/migrations/`.
|
||||
|
||||
### Apply migrations
|
||||
|
||||
```bash
|
||||
bun db:reset # Resets and applies all migrations
|
||||
```
|
||||
|
||||
### Create a new migration
|
||||
|
||||
```bash
|
||||
cd packages/db
|
||||
bunx supabase migration new <migration_name>
|
||||
```
|
||||
|
||||
## Database Schema
|
||||
|
||||
### Auth Tables (Better Auth)
|
||||
|
||||
- `users` - User accounts
|
||||
- `sessions` - Active sessions
|
||||
- `accounts` - OAuth provider accounts
|
||||
- `verification_tokens` - Magic link tokens
|
||||
|
||||
### Application Tables
|
||||
|
||||
- `properties` - User properties
|
||||
- `properties_addresses` - Property addresses with Google Maps data
|
||||
- `properties_models` - Scene graph models (versions)
|
||||
|
||||
## Usage
|
||||
|
||||
### Client-side (with RLS)
|
||||
|
||||
```typescript
|
||||
import { supabase } from '@pascal-app/db/client'
|
||||
|
||||
// RLS policies automatically filter by authenticated user
|
||||
const { data } = await supabase.from('properties').select('*')
|
||||
```
|
||||
|
||||
### Server-side (service role)
|
||||
|
||||
```typescript
|
||||
import { supabaseAdmin } from '@pascal-app/db/server'
|
||||
|
||||
// Bypasses RLS - you must manually filter by user_id
|
||||
const { data } = await supabaseAdmin
|
||||
.from('properties')
|
||||
.select('*')
|
||||
.eq('owner_id', userId)
|
||||
```
|
||||
|
||||
## Supabase Studio
|
||||
|
||||
Access the local Supabase Studio at: http://127.0.0.1:54323
|
||||
|
||||
Use this to:
|
||||
- Browse tables and data
|
||||
- Run SQL queries
|
||||
- View logs
|
||||
- Manage RLS policies
|
||||
@@ -1,22 +0,0 @@
|
||||
import { defineConfig } from 'drizzle-kit'
|
||||
|
||||
// Keep this in sync with `supabase/config.toml` -> `[db].port`.
|
||||
const LOCAL_SUPABASE_DB_URL = 'postgresql://postgres:postgres@127.0.0.1:55322/postgres'
|
||||
|
||||
export default defineConfig({
|
||||
schema: './src/schema/index.ts',
|
||||
out: '../../supabase/migrations',
|
||||
dialect: 'postgresql',
|
||||
dbCredentials: {
|
||||
url: process.env.POSTGRES_URL ?? LOCAL_SUPABASE_DB_URL,
|
||||
},
|
||||
schemaFilter: ['public'],
|
||||
introspect: {
|
||||
casing: 'camel',
|
||||
},
|
||||
migrations: {
|
||||
prefix: 'timestamp',
|
||||
},
|
||||
verbose: true,
|
||||
strict: true,
|
||||
})
|
||||
@@ -1,33 +0,0 @@
|
||||
{
|
||||
"name": "@pascal-app/db",
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"db:generate": "drizzle-kit generate --config drizzle.config.ts",
|
||||
"db:migrate": "bunx supabase --workdir ../.. db push --local",
|
||||
"db:push": "drizzle-kit push --config drizzle.config.ts",
|
||||
"db:studio": "drizzle-kit studio --config drizzle.config.ts"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"default": "./src/index.ts"
|
||||
},
|
||||
"./types": {
|
||||
"types": "./src/types.ts",
|
||||
"default": "./src/types.ts"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@supabase/supabase-js": "^2.95.3",
|
||||
"drizzle-orm": "^0.45.1",
|
||||
"drizzle-zod": "^0.8.3",
|
||||
"nanoid": "^5.0.9",
|
||||
"postgres": "^3.4.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@repo/typescript-config": "*",
|
||||
"drizzle-kit": "^0.31.9",
|
||||
"typescript": "5.9.3"
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import { drizzle } from 'drizzle-orm/postgres-js'
|
||||
import postgres from 'postgres'
|
||||
import * as schema from './schema'
|
||||
|
||||
const connectionString = process.env.POSTGRES_URL ?? ''
|
||||
|
||||
const client = postgres(connectionString, { prepare: false })
|
||||
|
||||
export const db = drizzle({ client, schema })
|
||||
|
||||
export type Database = typeof db
|
||||
@@ -1,56 +0,0 @@
|
||||
import { type AnyColumn, type SQL, sql } from 'drizzle-orm'
|
||||
import { text, timestamp } from 'drizzle-orm/pg-core'
|
||||
import { customAlphabet } from 'nanoid'
|
||||
|
||||
const alphabet = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'
|
||||
const nanoid = customAlphabet(alphabet, 16)
|
||||
|
||||
/**
|
||||
* Generate a unique ID with optional prefix
|
||||
* @example createId('user') => 'user_Abc123...'
|
||||
*/
|
||||
export const createId = (prefix?: string) => {
|
||||
const id = nanoid()
|
||||
return prefix ? `${prefix}_${id}` : id
|
||||
}
|
||||
|
||||
/**
|
||||
* Primary key column with auto-generated prefixed ID
|
||||
* @example id('user') => text('id').notNull().primaryKey().$defaultFn(() => createId('user'))
|
||||
*/
|
||||
export const id = (prefix?: string) =>
|
||||
text('id')
|
||||
.notNull()
|
||||
.primaryKey()
|
||||
.$defaultFn(() => createId(prefix))
|
||||
.$type<string>()
|
||||
|
||||
export const createdAt = timestamp('created_at', { withTimezone: true }).notNull().defaultNow()
|
||||
|
||||
export const updatedAt = timestamp('updated_at', { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow()
|
||||
.$onUpdate(() => new Date())
|
||||
|
||||
export const deletedAt = timestamp('deleted_at', { withTimezone: true })
|
||||
|
||||
/**
|
||||
* Standard timestamp columns for created_at and updated_at
|
||||
*/
|
||||
export const timestamps = {
|
||||
createdAt,
|
||||
updatedAt,
|
||||
}
|
||||
|
||||
// Alias for backwards compatibility with existing code
|
||||
export const timestampsColumns = timestamps
|
||||
|
||||
export const timestampsColumnsSoftDelete = {
|
||||
...timestampsColumns,
|
||||
deletedAt,
|
||||
}
|
||||
|
||||
/**
|
||||
* SQL helper for case-insensitive comparison
|
||||
*/
|
||||
export const lower = (column: AnyColumn): SQL => sql`lower(${column})`
|
||||
@@ -1,24 +0,0 @@
|
||||
/**
|
||||
* Database package
|
||||
* Exports Drizzle ORM and types
|
||||
* Note: Supabase clients are kept in the app's lib directory to avoid build-time initialization
|
||||
*/
|
||||
|
||||
export type { Database as SupabaseDatabase } from './types'
|
||||
|
||||
// Drizzle exports
|
||||
export { type Database, db } from './drizzle'
|
||||
export * from './schema'
|
||||
|
||||
|
||||
import * as dbSchema from './schema'
|
||||
export const schema = dbSchema
|
||||
export {
|
||||
createId,
|
||||
deletedAt,
|
||||
id,
|
||||
lower,
|
||||
timestamps,
|
||||
timestampsColumns,
|
||||
timestampsColumnsSoftDelete,
|
||||
} from './helpers'
|
||||
@@ -1,31 +0,0 @@
|
||||
import { pgTable } from 'drizzle-orm/pg-core'
|
||||
import { createInsertSchema, createSelectSchema } from 'drizzle-zod'
|
||||
import { id, timestamps } from '../../helpers'
|
||||
import { users } from './users'
|
||||
|
||||
export const accounts = pgTable('auth_accounts', (t) => ({
|
||||
id: id('account'),
|
||||
userId: t
|
||||
.text('user_id')
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
providerId: t.text('provider_id').notNull(),
|
||||
accountId: t.text('account_id').notNull(),
|
||||
password: t.text('password'),
|
||||
accessToken: t.text('access_token'),
|
||||
refreshToken: t.text('refresh_token'),
|
||||
idToken: t.text('id_token'),
|
||||
accessTokenExpiresAt: t.timestamp('access_token_expires_at', {
|
||||
withTimezone: true,
|
||||
}),
|
||||
refreshTokenExpiresAt: t.timestamp('refresh_token_expires_at', {
|
||||
withTimezone: true,
|
||||
}),
|
||||
scope: t.text('scope'),
|
||||
...timestamps,
|
||||
})).enableRLS()
|
||||
|
||||
export type Account = typeof accounts.$inferSelect
|
||||
export type NewAccount = typeof accounts.$inferInsert
|
||||
export const insertAccountSchema = createInsertSchema(accounts)
|
||||
export const selectAccountSchema = createSelectSchema(accounts)
|
||||
@@ -1,15 +0,0 @@
|
||||
import { pgTable } from 'drizzle-orm/pg-core'
|
||||
import { createInsertSchema, createSelectSchema } from 'drizzle-zod'
|
||||
import { createdAt, id } from '../../helpers'
|
||||
|
||||
export const jwks = pgTable('auth_jwks', (t) => ({
|
||||
id: id('jwks'),
|
||||
publicKey: t.text('public_key').notNull(),
|
||||
privateKey: t.text('private_key').notNull(),
|
||||
createdAt,
|
||||
})).enableRLS()
|
||||
|
||||
export type Jwks = typeof jwks.$inferSelect
|
||||
export type NewJwks = typeof jwks.$inferInsert
|
||||
export const insertJwksSchema = createInsertSchema(jwks)
|
||||
export const selectJwksSchema = createSelectSchema(jwks)
|
||||
@@ -1,28 +0,0 @@
|
||||
import { pgTable } from 'drizzle-orm/pg-core'
|
||||
import { createInsertSchema, createSelectSchema } from 'drizzle-zod'
|
||||
import { id, timestamps } from '../../helpers'
|
||||
import { users } from './users'
|
||||
|
||||
export const sessions = pgTable('auth_sessions', (t) => ({
|
||||
id: id('session'),
|
||||
userId: t
|
||||
.text('user_id')
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
expiresAt: t.timestamp('expires_at', { withTimezone: true }),
|
||||
token: t.text('token').notNull(),
|
||||
ipAddress: t.text('ip_address'),
|
||||
userAgent: t.text('user_agent'),
|
||||
// Custom: active project for the session context
|
||||
activeProjectId: t.text('active_project_id'),
|
||||
// Admin plugin support: tracks who is impersonating this session
|
||||
impersonatedBy: t
|
||||
.text('impersonated_by')
|
||||
.references(() => users.id, { onDelete: 'set null' }),
|
||||
...timestamps,
|
||||
})).enableRLS()
|
||||
|
||||
export type Session = typeof sessions.$inferSelect
|
||||
export type NewSession = typeof sessions.$inferInsert
|
||||
export const insertSessionSchema = createInsertSchema(sessions)
|
||||
export const selectSessionSchema = createSelectSchema(sessions)
|
||||
@@ -1,41 +0,0 @@
|
||||
import { pgEnum, pgTable, uniqueIndex } from 'drizzle-orm/pg-core'
|
||||
import { createInsertSchema, createSelectSchema } from 'drizzle-zod'
|
||||
import { id, lower, timestampsColumns } from '../../helpers'
|
||||
|
||||
export const USER_ROLES = ['user', 'admin'] as const
|
||||
export const userRoles = pgEnum('auth_user_roles', USER_ROLES)
|
||||
|
||||
export const users = pgTable(
|
||||
'auth_users',
|
||||
(t) => ({
|
||||
id: id('user'),
|
||||
email: t.text('email').notNull(),
|
||||
emailVerified: t.boolean('email_verified').notNull().default(false),
|
||||
name: t.text('name').notNull(),
|
||||
image: t.text('image'),
|
||||
/** Public username for the community hub */
|
||||
username: t.text('username'),
|
||||
/** GitHub profile URL */
|
||||
githubUrl: t.text('github_url'),
|
||||
/** X/Twitter profile URL */
|
||||
xUrl: t.text('x_url'),
|
||||
/** YouTube channel URL */
|
||||
youtubeUrl: t.text('youtube_url'),
|
||||
/** Whether the user wants to receive email notifications about new features and updates */
|
||||
emailNotifications: t.boolean('email_notifications').notNull().default(true),
|
||||
role: userRoles('role').notNull().default('user'),
|
||||
banned: t.boolean('banned').notNull().default(false),
|
||||
banReason: t.text('ban_reason'),
|
||||
banExpires: t.timestamp('ban_expires', { withTimezone: true }),
|
||||
...timestampsColumns,
|
||||
}),
|
||||
(t) => [
|
||||
uniqueIndex('email_unique_index').on(lower(t.email)),
|
||||
uniqueIndex('username_unique_index').on(lower(t.username)),
|
||||
],
|
||||
).enableRLS()
|
||||
|
||||
export type User = typeof users.$inferSelect
|
||||
export type NewUser = typeof users.$inferInsert
|
||||
export const insertUserSchema = createInsertSchema(users)
|
||||
export const selectUserSchema = createSelectSchema(users)
|
||||
@@ -1,22 +0,0 @@
|
||||
import { index, pgTable } from 'drizzle-orm/pg-core'
|
||||
import { createInsertSchema, createSelectSchema } from 'drizzle-zod'
|
||||
import { id, timestamps } from '../../helpers'
|
||||
|
||||
export const verifications = pgTable(
|
||||
'auth_verifications',
|
||||
(t) => ({
|
||||
id: id('verification'),
|
||||
value: t.text('value').notNull(),
|
||||
identifier: t.text('identifier').notNull(),
|
||||
expiresAt: t.timestamp('expires_at', {
|
||||
withTimezone: true,
|
||||
}),
|
||||
...timestamps,
|
||||
}),
|
||||
(t) => [index('verification_identifier_index').on(t.identifier)],
|
||||
).enableRLS()
|
||||
|
||||
export type Verification = typeof verifications.$inferSelect
|
||||
export type NewVerification = typeof verifications.$inferInsert
|
||||
export const insertVerificationSchema = createInsertSchema(verifications)
|
||||
export const selectVerificationSchema = createSelectSchema(verifications)
|
||||
@@ -1,18 +0,0 @@
|
||||
import { pgTable } from 'drizzle-orm/pg-core'
|
||||
import { createInsertSchema, createSelectSchema } from 'drizzle-zod'
|
||||
import { id, createdAt } from '../../helpers'
|
||||
|
||||
export const feedback = pgTable('feedback', (t) => ({
|
||||
id: id('feedback'),
|
||||
userId: t.text('user_id'),
|
||||
projectId: t.text('project_id'),
|
||||
message: t.text('message').notNull(),
|
||||
images: t.jsonb('images').$type<string[]>(),
|
||||
sceneGraph: t.jsonb('scene_graph'),
|
||||
createdAt,
|
||||
})).enableRLS()
|
||||
|
||||
export type Feedback = typeof feedback.$inferSelect
|
||||
export type NewFeedback = typeof feedback.$inferInsert
|
||||
export const insertFeedbackSchema = createInsertSchema(feedback)
|
||||
export const selectFeedbackSchema = createSelectSchema(feedback)
|
||||
@@ -1,19 +0,0 @@
|
||||
// Auth tables
|
||||
export * from './auth/accounts'
|
||||
export * from './auth/jwks'
|
||||
export * from './auth/sessions'
|
||||
export * from './auth/users'
|
||||
export * from './auth/verifications'
|
||||
|
||||
// Feedback table
|
||||
export * from './feedback/feedback'
|
||||
|
||||
// Project tables
|
||||
export * from './projects/addresses'
|
||||
export * from './projects/assets'
|
||||
export * from './projects/likes'
|
||||
export * from './projects/models'
|
||||
export * from './projects/projects'
|
||||
|
||||
// Presets table
|
||||
export * from './presets/presets'
|
||||
@@ -1,30 +0,0 @@
|
||||
import { pgTable, index, text, boolean, jsonb } from 'drizzle-orm/pg-core'
|
||||
import { createInsertSchema, createSelectSchema } from 'drizzle-zod'
|
||||
import { id, timestampsColumns } from '../../helpers'
|
||||
import { users } from '../auth/users'
|
||||
|
||||
export const presets = pgTable(
|
||||
'presets',
|
||||
(t) => ({
|
||||
id: id('preset'),
|
||||
type: t.text('type').notNull(), // 'door' | 'window'
|
||||
name: t.text('name').notNull(),
|
||||
data: t.jsonb('data').notNull(),
|
||||
thumbnailUrl: t.text('thumbnail_url'),
|
||||
userId: t
|
||||
.text('user_id')
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
isCommunity: t.boolean('is_community').notNull().default(false),
|
||||
...timestampsColumns,
|
||||
}),
|
||||
(t) => [
|
||||
index('presets_type_idx').on(t.type),
|
||||
index('presets_user_id_idx').on(t.userId),
|
||||
index('presets_is_community_idx').on(t.isCommunity),
|
||||
],
|
||||
).enableRLS()
|
||||
|
||||
export type Preset = typeof presets.$inferSelect
|
||||
export type NewPreset = typeof presets.$inferInsert
|
||||
export const insertPresetSchema = createInsertSchema(presets)
|
||||
export const selectPresetSchema = createSelectSchema(presets)
|
||||
@@ -1,53 +0,0 @@
|
||||
import { pgTable, unique } from 'drizzle-orm/pg-core'
|
||||
import { z } from 'zod'
|
||||
import { id, timestampsColumns } from '../../helpers'
|
||||
|
||||
export const addresses = pgTable(
|
||||
'projects_addresses',
|
||||
(t) => ({
|
||||
id: id('address'),
|
||||
streetNumber: t.text('street_number'),
|
||||
route: t.text('route'),
|
||||
routeShort: t.text('route_short'),
|
||||
neighborhood: t.text('neighborhood'),
|
||||
city: t.text('city'),
|
||||
county: t.text('county'),
|
||||
state: t.text('state'),
|
||||
stateLong: t.text('state_long'),
|
||||
postalCode: t.text('postal_code'),
|
||||
postalCodeSuffix: t.text('postal_code_suffix'),
|
||||
country: t.text('country'),
|
||||
countryLong: t.text('country_long'),
|
||||
latitude: t.numeric('latitude'),
|
||||
longitude: t.numeric('longitude'),
|
||||
rawJson: t.jsonb('raw_json'),
|
||||
...timestampsColumns,
|
||||
}),
|
||||
(t) => [
|
||||
// Unique constraint on core address components to prevent duplicates
|
||||
unique('address_components_unique').on(t.streetNumber, t.route, t.city, t.state, t.postalCode),
|
||||
],
|
||||
).enableRLS()
|
||||
|
||||
// Create address schema manually to avoid issues with generated columns
|
||||
export const addressSchema = z.object({
|
||||
streetNumber: z.string().optional(),
|
||||
route: z.string().optional(),
|
||||
routeShort: z.string().optional(),
|
||||
neighborhood: z.string().optional(),
|
||||
city: z.string().optional(),
|
||||
county: z.string().optional(),
|
||||
state: z.string().optional(),
|
||||
stateLong: z.string().optional(),
|
||||
postalCode: z.string().optional(),
|
||||
postalCodeSuffix: z.string().optional(),
|
||||
country: z.string().default('US'),
|
||||
countryLong: z.string().optional(),
|
||||
latitude: z.string().optional(),
|
||||
longitude: z.string().optional(),
|
||||
rawJson: z.record(z.string(), z.unknown()).optional(),
|
||||
})
|
||||
|
||||
export type AddressSchema = z.infer<typeof addressSchema>
|
||||
export type Address = typeof addresses.$inferSelect
|
||||
export type NewAddress = typeof addresses.$inferInsert
|
||||
@@ -1,25 +0,0 @@
|
||||
import { relations } from 'drizzle-orm'
|
||||
import { pgTable } from 'drizzle-orm/pg-core'
|
||||
import { id, timestampsColumns } from '../../helpers'
|
||||
import { projects } from './projects'
|
||||
|
||||
export const projectAssets = pgTable('project_assets', (t) => ({
|
||||
id: id('asset'),
|
||||
projectId: t.text('project_id').notNull().references(() => projects.id, { onDelete: 'cascade' }),
|
||||
storageKey: t.text('storage_key').notNull(),
|
||||
url: t.text('url').notNull(),
|
||||
type: t.text('type').notNull(), // 'scan' | 'guide'
|
||||
originalName: t.text('original_name'),
|
||||
mimeType: t.text('mime_type'),
|
||||
...timestampsColumns,
|
||||
})).enableRLS()
|
||||
|
||||
export const projectAssetsRelations = relations(projectAssets, ({ one }) => ({
|
||||
project: one(projects, {
|
||||
fields: [projectAssets.projectId],
|
||||
references: [projects.id],
|
||||
}),
|
||||
}))
|
||||
|
||||
export type ProjectAsset = typeof projectAssets.$inferSelect
|
||||
export type NewProjectAsset = typeof projectAssets.$inferInsert
|
||||
@@ -1,36 +0,0 @@
|
||||
import { relations } from 'drizzle-orm'
|
||||
import { pgTable, unique } from 'drizzle-orm/pg-core'
|
||||
import { createInsertSchema, createSelectSchema } from 'drizzle-zod'
|
||||
import { id, createdAt } from '../../helpers'
|
||||
import { projects } from './projects'
|
||||
import { users } from '../auth/users'
|
||||
|
||||
export const projectsLikes = pgTable(
|
||||
'projects_likes',
|
||||
(t) => ({
|
||||
id: id('like'),
|
||||
projectId: t
|
||||
.text('project_id')
|
||||
.notNull()
|
||||
.references(() => projects.id, { onDelete: 'cascade' }),
|
||||
userId: t
|
||||
.text('user_id')
|
||||
.notNull(),
|
||||
createdAt,
|
||||
}),
|
||||
(t) => [
|
||||
unique('projects_likes_project_user_unique').on(t.projectId, t.userId),
|
||||
],
|
||||
).enableRLS()
|
||||
|
||||
export const projectsLikesRelations = relations(projectsLikes, ({ one }) => ({
|
||||
project: one(projects, {
|
||||
fields: [projectsLikes.projectId],
|
||||
references: [projects.id],
|
||||
}),
|
||||
}))
|
||||
|
||||
export type ProjectLike = typeof projectsLikes.$inferSelect
|
||||
export type NewProjectLike = typeof projectsLikes.$inferInsert
|
||||
export const insertProjectLikeSchema = createInsertSchema(projectsLikes)
|
||||
export const selectProjectLikeSchema = createSelectSchema(projectsLikes)
|
||||
@@ -1,31 +0,0 @@
|
||||
import { relations } from 'drizzle-orm'
|
||||
import { pgTable } from 'drizzle-orm/pg-core'
|
||||
import { createInsertSchema, createSelectSchema } from 'drizzle-zod'
|
||||
import { id, timestampsColumnsSoftDelete } from '../../helpers'
|
||||
import { projects } from './projects'
|
||||
|
||||
export const models = pgTable('projects_models', (t) => ({
|
||||
id: id('model'),
|
||||
name: t.text('name'),
|
||||
version: t.integer('version').default(1),
|
||||
description: t.text('description'),
|
||||
draft: t.boolean('draft').default(true),
|
||||
projectId: t
|
||||
.text('project_id')
|
||||
.references(() => projects.id, { onDelete: 'set null' }),
|
||||
sceneGraph: t.jsonb('scene_graph'),
|
||||
metadata: t.jsonb('metadata'),
|
||||
...timestampsColumnsSoftDelete,
|
||||
})).enableRLS()
|
||||
|
||||
export const modelsRelations = relations(models, ({ one }) => ({
|
||||
project: one(projects, {
|
||||
fields: [models.projectId],
|
||||
references: [projects.id],
|
||||
}),
|
||||
}))
|
||||
|
||||
export type Model = typeof models.$inferSelect
|
||||
export type NewModel = typeof models.$inferInsert
|
||||
export const insertModelSchema = createInsertSchema(models)
|
||||
export const selectModelSchema = createSelectSchema(models)
|
||||
@@ -1,55 +0,0 @@
|
||||
import { relations } from 'drizzle-orm'
|
||||
import { index, pgTable } from 'drizzle-orm/pg-core'
|
||||
import { createInsertSchema, createSelectSchema } from 'drizzle-zod'
|
||||
import { id, timestampsColumns } from '../../helpers'
|
||||
import { users } from '../auth/users'
|
||||
import { addresses } from './addresses'
|
||||
|
||||
export const projects = pgTable(
|
||||
'projects',
|
||||
(t) => ({
|
||||
id: id('project'),
|
||||
name: t.text('name'),
|
||||
addressId: t
|
||||
.text('address_id')
|
||||
.references(() => addresses.id, { onDelete: 'set null' }),
|
||||
ownerId: t
|
||||
.text('owner_id')
|
||||
.references(() => users.id, { onDelete: 'set null' }),
|
||||
detailsJson: t.jsonb('details_json'),
|
||||
metadata: t.jsonb('metadata'),
|
||||
publishedModelVersion: t.integer('published_model_version'),
|
||||
// Community features
|
||||
isPrivate: t.boolean('is_private').notNull().default(true),
|
||||
isEmpty: t.boolean('is_empty').notNull().default(true),
|
||||
showScansPublic: t.boolean('show_scans_public').notNull().default(true),
|
||||
showGuidesPublic: t.boolean('show_guides_public').notNull().default(true),
|
||||
views: t.integer('views').notNull().default(0),
|
||||
likes: t.integer('likes').notNull().default(0),
|
||||
thumbnailUrl: t.text('thumbnail_url'),
|
||||
...timestampsColumns,
|
||||
}),
|
||||
(t) => [
|
||||
index('project_address_idx').on(t.addressId),
|
||||
index('project_owner_idx').on(t.ownerId),
|
||||
index('project_is_private_idx').on(t.isPrivate),
|
||||
index('project_views_idx').on(t.views),
|
||||
index('project_likes_idx').on(t.likes),
|
||||
],
|
||||
).enableRLS()
|
||||
|
||||
export const projectsRelations = relations(projects, ({ one }) => ({
|
||||
address: one(addresses, {
|
||||
fields: [projects.addressId],
|
||||
references: [addresses.id],
|
||||
}),
|
||||
owner: one(users, {
|
||||
fields: [projects.ownerId],
|
||||
references: [users.id],
|
||||
}),
|
||||
}))
|
||||
|
||||
export type Project = typeof projects.$inferSelect
|
||||
export type NewProject = typeof projects.$inferInsert
|
||||
export const insertProjectSchema = createInsertSchema(projects)
|
||||
export const selectProjectSchema = createSelectSchema(projects)
|
||||
@@ -1,160 +0,0 @@
|
||||
/**
|
||||
* Database types for Supabase
|
||||
* Generated from database schema
|
||||
*/
|
||||
|
||||
export type Json = string | number | boolean | null | { [key: string]: Json | undefined } | Json[]
|
||||
|
||||
export interface Database {
|
||||
public: {
|
||||
Tables: {
|
||||
projects: {
|
||||
Row: {
|
||||
id: string
|
||||
name: string
|
||||
owner_id: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
Insert: {
|
||||
id?: string
|
||||
name: string
|
||||
owner_id: string
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
Update: {
|
||||
id?: string
|
||||
name?: string
|
||||
owner_id?: string
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
}
|
||||
projects_addresses: {
|
||||
Row: {
|
||||
id: string
|
||||
project_id: string
|
||||
formatted_address: string
|
||||
street_number: string | null
|
||||
route: string | null
|
||||
locality: string | null
|
||||
administrative_area_level_1: string | null
|
||||
administrative_area_level_2: string | null
|
||||
country: string | null
|
||||
postal_code: string | null
|
||||
latitude: number | null
|
||||
longitude: number | null
|
||||
place_id: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
Insert: {
|
||||
id?: string
|
||||
project_id: string
|
||||
formatted_address: string
|
||||
street_number?: string | null
|
||||
route?: string | null
|
||||
locality?: string | null
|
||||
administrative_area_level_1?: string | null
|
||||
administrative_area_level_2?: string | null
|
||||
country?: string | null
|
||||
postal_code?: string | null
|
||||
latitude?: number | null
|
||||
longitude?: number | null
|
||||
place_id?: string | null
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
Update: {
|
||||
id?: string
|
||||
project_id?: string
|
||||
formatted_address?: string
|
||||
street_number?: string | null
|
||||
route?: string | null
|
||||
locality?: string | null
|
||||
administrative_area_level_1?: string | null
|
||||
administrative_area_level_2?: string | null
|
||||
country?: string | null
|
||||
postal_code?: string | null
|
||||
latitude?: number | null
|
||||
longitude?: number | null
|
||||
place_id?: string | null
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
}
|
||||
projects_models: {
|
||||
Row: {
|
||||
id: string
|
||||
project_id: string
|
||||
name: string
|
||||
version: number
|
||||
draft: boolean
|
||||
scene_graph: Json | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
Insert: {
|
||||
id?: string
|
||||
project_id: string
|
||||
name: string
|
||||
version?: number
|
||||
draft?: boolean
|
||||
scene_graph?: Json | null
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
Update: {
|
||||
id?: string
|
||||
project_id?: string
|
||||
name?: string
|
||||
version?: number
|
||||
draft?: boolean
|
||||
scene_graph?: Json | null
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
}
|
||||
presets: {
|
||||
Row: {
|
||||
id: string
|
||||
type: string
|
||||
name: string
|
||||
data: Json
|
||||
thumbnail_url: string | null
|
||||
user_id: string | null
|
||||
is_community: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
Insert: {
|
||||
id?: string
|
||||
type: string
|
||||
name: string
|
||||
data: Json
|
||||
thumbnail_url?: string | null
|
||||
user_id?: string | null
|
||||
is_community?: boolean
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
Update: {
|
||||
id?: string
|
||||
type?: string
|
||||
name?: string
|
||||
data?: Json
|
||||
thumbnail_url?: string | null
|
||||
user_id?: string | null
|
||||
is_community?: boolean
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
Relationships: []
|
||||
}
|
||||
}
|
||||
Views: {}
|
||||
Functions: {}
|
||||
Enums: {}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"extends": "@repo/typescript-config/base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler"
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -39,7 +39,7 @@
|
||||
"zustand": "^5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@repo/typescript-config": "*",
|
||||
"@pascal/typescript-config": "*",
|
||||
"@types/react": "^19.2.2",
|
||||
"typescript": "5.9.3",
|
||||
"@types/three": "^0.183.0"
|
||||
|
||||
@@ -1,6 +1,18 @@
|
||||
import type { ThreeEvent } from '@react-three/fiber'
|
||||
import mitt from 'mitt'
|
||||
import type { BuildingNode, CeilingNode, DoorNode, ItemNode, LevelNode, RoofNode, SiteNode, SlabNode, WallNode, WindowNode, ZoneNode } from '../schema'
|
||||
import type {
|
||||
BuildingNode,
|
||||
CeilingNode,
|
||||
DoorNode,
|
||||
ItemNode,
|
||||
LevelNode,
|
||||
RoofNode,
|
||||
SiteNode,
|
||||
SlabNode,
|
||||
WallNode,
|
||||
WindowNode,
|
||||
ZoneNode,
|
||||
} from '../schema'
|
||||
import type { AnyNode } from '../schema/types'
|
||||
|
||||
// Base event interfaces
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useLayoutEffect } from "react";
|
||||
import type * as THREE from "three";
|
||||
import { useLayoutEffect } from 'react'
|
||||
import type * as THREE from 'three'
|
||||
|
||||
export const sceneRegistry = {
|
||||
// Master lookup: ID -> Object3D
|
||||
@@ -22,7 +22,7 @@ export const sceneRegistry = {
|
||||
window: new Set<string>(),
|
||||
door: new Set<string>(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function useRegistry(
|
||||
id: string,
|
||||
@@ -30,19 +30,19 @@ export function useRegistry(
|
||||
ref: React.RefObject<THREE.Object3D>,
|
||||
) {
|
||||
useLayoutEffect(() => {
|
||||
const obj = ref.current;
|
||||
if (!obj) return;
|
||||
const obj = ref.current
|
||||
if (!obj) return
|
||||
|
||||
// 1. Add to master map
|
||||
sceneRegistry.nodes.set(id, obj);
|
||||
sceneRegistry.nodes.set(id, obj)
|
||||
|
||||
// 2. Add to type-specific set
|
||||
sceneRegistry.byType[type].add(id);
|
||||
sceneRegistry.byType[type].add(id)
|
||||
|
||||
// 4. Cleanup when component unmounts
|
||||
return () => {
|
||||
sceneRegistry.nodes.delete(id);
|
||||
sceneRegistry.byType[type].delete(id);
|
||||
};
|
||||
}, [id, type, ref]);
|
||||
sceneRegistry.nodes.delete(id)
|
||||
sceneRegistry.byType[type].delete(id)
|
||||
}
|
||||
}, [id, type, ref])
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { getScaledDimensions } from '../../schema'
|
||||
import type { AnyNode, CeilingNode, ItemNode, SlabNode, WallNode } from '../../schema'
|
||||
import { getScaledDimensions } from '../../schema'
|
||||
import { SpatialGrid } from './spatial-grid'
|
||||
import { WallSpatialGrid } from './wall-spatial-grid'
|
||||
|
||||
@@ -14,10 +14,12 @@ export function pointInPolygon(px: number, pz: number, polygon: Array<[number, n
|
||||
let inside = false
|
||||
const n = polygon.length
|
||||
for (let i = 0, j = n - 1; i < n; j = i++) {
|
||||
const xi = polygon[i]![0], zi = polygon[i]![1]
|
||||
const xj = polygon[j]![0], zj = polygon[j]![1]
|
||||
const xi = polygon[i]![0],
|
||||
zi = polygon[i]![1]
|
||||
const xj = polygon[j]![0],
|
||||
zj = polygon[j]![1]
|
||||
|
||||
if ((zi > pz) !== (zj > pz) && px < ((xj - xi) * (pz - zi)) / (zj - zi) + xi) {
|
||||
if (zi > pz !== zj > pz && px < ((xj - xi) * (pz - zi)) / (zj - zi) + xi) {
|
||||
inside = !inside
|
||||
}
|
||||
}
|
||||
@@ -53,8 +55,14 @@ function getItemFootprint(
|
||||
* Test if two line segments (a1->a2) and (b1->b2) intersect.
|
||||
*/
|
||||
function segmentsIntersect(
|
||||
ax1: number, az1: number, ax2: number, az2: number,
|
||||
bx1: number, bz1: number, bx2: number, bz2: number,
|
||||
ax1: number,
|
||||
az1: number,
|
||||
ax2: number,
|
||||
az2: number,
|
||||
bx1: number,
|
||||
bz1: number,
|
||||
bx2: number,
|
||||
bz2: number,
|
||||
): boolean {
|
||||
const cross = (ox: number, oz: number, ax: number, az: number, bx: number, bz: number) =>
|
||||
(ax - ox) * (bz - oz) - (az - oz) * (bx - ox)
|
||||
@@ -64,15 +72,16 @@ function segmentsIntersect(
|
||||
const d3 = cross(ax1, az1, ax2, az2, bx1, bz1)
|
||||
const d4 = cross(ax1, az1, ax2, az2, bx2, bz2)
|
||||
|
||||
if (((d1 > 0 && d2 < 0) || (d1 < 0 && d2 > 0)) &&
|
||||
((d3 > 0 && d4 < 0) || (d3 < 0 && d4 > 0))) {
|
||||
if (((d1 > 0 && d2 < 0) || (d1 < 0 && d2 > 0)) && ((d3 > 0 && d4 < 0) || (d3 < 0 && d4 > 0))) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Collinear touching cases
|
||||
const onSeg = (px: number, pz: number, qx: number, qz: number, rx: number, rz: number) =>
|
||||
Math.min(px, qx) <= rx && rx <= Math.max(px, qx) &&
|
||||
Math.min(pz, qz) <= rz && rz <= Math.max(pz, qz)
|
||||
Math.min(px, qx) <= rx &&
|
||||
rx <= Math.max(px, qx) &&
|
||||
Math.min(pz, qz) <= rz &&
|
||||
rz <= Math.max(pz, qz)
|
||||
|
||||
if (d1 === 0 && onSeg(bx1, bz1, bx2, bz2, ax1, az1)) return true
|
||||
if (d2 === 0 && onSeg(bx1, bz1, bx2, bz2, ax2, az2)) return true
|
||||
@@ -86,16 +95,27 @@ function segmentsIntersect(
|
||||
* Test if a line segment intersects any edge of a polygon.
|
||||
*/
|
||||
function segmentIntersectsPolygon(
|
||||
sx1: number, sz1: number, sx2: number, sz2: number,
|
||||
sx1: number,
|
||||
sz1: number,
|
||||
sx2: number,
|
||||
sz2: number,
|
||||
polygon: Array<[number, number]>,
|
||||
): boolean {
|
||||
const n = polygon.length
|
||||
for (let i = 0; i < n; i++) {
|
||||
const j = (i + 1) % n
|
||||
if (segmentsIntersect(
|
||||
sx1, sz1, sx2, sz2,
|
||||
polygon[i]![0], polygon[i]![1], polygon[j]![0], polygon[j]![1],
|
||||
)) {
|
||||
if (
|
||||
segmentsIntersect(
|
||||
sx1,
|
||||
sz1,
|
||||
sx2,
|
||||
sz2,
|
||||
polygon[i]![0],
|
||||
polygon[i]![1],
|
||||
polygon[j]![0],
|
||||
polygon[j]![1],
|
||||
)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -129,10 +149,16 @@ export function itemOverlapsPolygon(
|
||||
// Check if any item edge intersects any polygon edge
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const j = (i + 1) % 4
|
||||
if (segmentIntersectsPolygon(
|
||||
corners[i]![0], corners[i]![1], corners[j]![0], corners[j]![1],
|
||||
polygon,
|
||||
)) return true
|
||||
if (
|
||||
segmentIntersectsPolygon(
|
||||
corners[i]![0],
|
||||
corners[i]![1],
|
||||
corners[j]![0],
|
||||
corners[j]![1],
|
||||
polygon,
|
||||
)
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
@@ -144,8 +170,14 @@ export function itemOverlapsPolygon(
|
||||
* This prevents walls that just touch one point from being detected.
|
||||
*/
|
||||
function segmentsCollinearAndOverlap(
|
||||
ax1: number, az1: number, ax2: number, az2: number,
|
||||
bx1: number, bz1: number, bx2: number, bz2: number,
|
||||
ax1: number,
|
||||
az1: number,
|
||||
ax2: number,
|
||||
az2: number,
|
||||
bx1: number,
|
||||
bz1: number,
|
||||
bx2: number,
|
||||
bz2: number,
|
||||
): boolean {
|
||||
const EPSILON = 1e-6
|
||||
|
||||
@@ -159,8 +191,10 @@ function segmentsCollinearAndOverlap(
|
||||
|
||||
// Check if a point is on segment b
|
||||
const onSegment = (px: number, pz: number, qx: number, qz: number, rx: number, rz: number) =>
|
||||
Math.min(px, qx) - EPSILON <= rx && rx <= Math.max(px, qx) + EPSILON &&
|
||||
Math.min(pz, qz) - EPSILON <= rz && rz <= Math.max(pz, qz) + EPSILON
|
||||
Math.min(px, qx) - EPSILON <= rx &&
|
||||
rx <= Math.max(px, qx) + EPSILON &&
|
||||
Math.min(pz, qz) - EPSILON <= rz &&
|
||||
rz <= Math.max(pz, qz) + EPSILON
|
||||
|
||||
// BOTH endpoints of wall (a) must be on edge (b) for substantial overlap
|
||||
const a1OnB = onSegment(bx1, bz1, bx2, bz2, ax1, az1)
|
||||
@@ -314,7 +348,7 @@ export class SpatialGridManager {
|
||||
// position[1] is the bottom of the item
|
||||
this.getWallGrid(levelId).insert({
|
||||
itemId: item.id,
|
||||
wallId: wallId,
|
||||
wallId,
|
||||
tStart: t - halfW,
|
||||
tEnd: t + halfW,
|
||||
yStart: item.position[1],
|
||||
@@ -328,7 +362,12 @@ export class SpatialGridManager {
|
||||
// Ceiling item - use parentId as the ceiling ID
|
||||
const ceilingId = item.parentId
|
||||
if (ceilingId && this.ceilings.has(ceilingId)) {
|
||||
this.getCeilingGrid(ceilingId).insert(item.id, item.position, getScaledDimensions(item), item.rotation)
|
||||
this.getCeilingGrid(ceilingId).insert(
|
||||
item.id,
|
||||
item.position,
|
||||
getScaledDimensions(item),
|
||||
item.rotation,
|
||||
)
|
||||
this.itemCeilingMap.set(item.id, ceilingId)
|
||||
}
|
||||
} else if (!item.asset.attachTo) {
|
||||
@@ -367,7 +406,7 @@ export class SpatialGridManager {
|
||||
// position[1] is the bottom of the item
|
||||
this.getWallGrid(levelId).insert({
|
||||
itemId: item.id,
|
||||
wallId: wallId,
|
||||
wallId,
|
||||
tStart: t - halfW,
|
||||
tEnd: t + halfW,
|
||||
yStart: item.position[1],
|
||||
@@ -387,7 +426,12 @@ export class SpatialGridManager {
|
||||
// Insert into new ceiling grid
|
||||
const ceilingId = item.parentId
|
||||
if (ceilingId && this.ceilings.has(ceilingId)) {
|
||||
this.getCeilingGrid(ceilingId).insert(item.id, item.position, getScaledDimensions(item), item.rotation)
|
||||
this.getCeilingGrid(ceilingId).insert(
|
||||
item.id,
|
||||
item.position,
|
||||
getScaledDimensions(item),
|
||||
item.rotation,
|
||||
)
|
||||
this.itemCeilingMap.set(item.id, ceilingId)
|
||||
}
|
||||
} else if (!item.asset.attachTo) {
|
||||
@@ -530,9 +574,12 @@ export class SpatialGridManager {
|
||||
const slabMap = this.slabsByLevel.get(levelId)
|
||||
if (!slabMap) return 0
|
||||
|
||||
let maxElevation = -Infinity
|
||||
let maxElevation = Number.NEGATIVE_INFINITY
|
||||
for (const slab of slabMap.values()) {
|
||||
if (slab.polygon.length >= 3 && itemOverlapsPolygon(position, dimensions, rotation, slab.polygon, 0.01)) {
|
||||
if (
|
||||
slab.polygon.length >= 3 &&
|
||||
itemOverlapsPolygon(position, dimensions, rotation, slab.polygon, 0.01)
|
||||
) {
|
||||
// Check if item is entirely within a hole (if so, ignore this slab)
|
||||
// We consider it entirely in a hole if the item center is in the hole
|
||||
let inHole = false
|
||||
@@ -553,7 +600,7 @@ export class SpatialGridManager {
|
||||
}
|
||||
}
|
||||
}
|
||||
return maxElevation === -Infinity ? 0 : maxElevation
|
||||
return maxElevation === Number.NEGATIVE_INFINITY ? 0 : maxElevation
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -561,15 +608,11 @@ export class SpatialGridManager {
|
||||
* Uses wallOverlapsPolygon which handles edge cases (points on boundary, collinear segments).
|
||||
* Returns the highest slab elevation found, or 0 if none.
|
||||
*/
|
||||
getSlabElevationForWall(
|
||||
levelId: string,
|
||||
start: [number, number],
|
||||
end: [number, number],
|
||||
): number {
|
||||
getSlabElevationForWall(levelId: string, start: [number, number], end: [number, number]): number {
|
||||
const slabMap = this.slabsByLevel.get(levelId)
|
||||
if (!slabMap) return 0
|
||||
|
||||
let maxElevation = -Infinity
|
||||
let maxElevation = Number.NEGATIVE_INFINITY
|
||||
for (const slab of slabMap.values()) {
|
||||
if (slab.polygon.length < 3) continue
|
||||
if (!wallOverlapsPolygon(start, end, slab.polygon)) continue
|
||||
@@ -609,7 +652,7 @@ export class SpatialGridManager {
|
||||
if (elevation > maxElevation) maxElevation = elevation
|
||||
}
|
||||
}
|
||||
return maxElevation === -Infinity ? 0 : maxElevation
|
||||
return maxElevation === Number.NEGATIVE_INFINITY ? 0 : maxElevation
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
import { getScaledDimensions, type AnyNode, type AnyNodeId, type ItemNode, type SlabNode, type WallNode } from '../../schema'
|
||||
import {
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
getScaledDimensions,
|
||||
type ItemNode,
|
||||
type SlabNode,
|
||||
type WallNode,
|
||||
} from '../../schema'
|
||||
import useScene from '../../store/use-scene'
|
||||
import { itemOverlapsPolygon, spatialGridManager, wallOverlapsPolygon } from './spatial-grid-manager'
|
||||
import {
|
||||
itemOverlapsPolygon,
|
||||
spatialGridManager,
|
||||
wallOverlapsPolygon,
|
||||
} from './spatial-grid-manager'
|
||||
|
||||
export function resolveLevelId(node: AnyNode, nodes: Record<string, AnyNode>): string {
|
||||
// If the node itself is a level
|
||||
@@ -13,10 +24,10 @@ export function resolveLevelId(node: AnyNode, nodes: Record<string, AnyNode>): s
|
||||
while (current) {
|
||||
if (current.type === 'level') return current.id
|
||||
// Find parent (you might need to add parentId to your schema or derive it)
|
||||
if (!current.parentId) {
|
||||
current = undefined
|
||||
} else {
|
||||
if (current.parentId) {
|
||||
current = nodes[current.parentId]
|
||||
} else {
|
||||
current = undefined
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,9 +82,11 @@ export function initSpatialGridSync() {
|
||||
|
||||
if (node.type === 'item' && prev.type === 'item') {
|
||||
if (
|
||||
!arraysEqual(node.position, prev.position) ||
|
||||
!arraysEqual(node.rotation, prev.rotation) ||
|
||||
!arraysEqual(node.scale, prev.scale) ||
|
||||
!(
|
||||
arraysEqual(node.position, prev.position) &&
|
||||
arraysEqual(node.rotation, prev.rotation) &&
|
||||
arraysEqual(node.scale, prev.scale)
|
||||
) ||
|
||||
node.parentId !== prev.parentId ||
|
||||
node.side !== prev.side
|
||||
) {
|
||||
@@ -85,7 +98,11 @@ export function initSpatialGridSync() {
|
||||
}
|
||||
}
|
||||
} else if (node.type === 'slab' && prev.type === 'slab') {
|
||||
if (node.polygon !== prev.polygon || node.elevation !== prev.elevation || node.holes !== prev.holes) {
|
||||
if (
|
||||
node.polygon !== prev.polygon ||
|
||||
node.elevation !== prev.elevation ||
|
||||
node.holes !== prev.holes
|
||||
) {
|
||||
const levelId = resolveLevelId(node, state.nodes)
|
||||
spatialGridManager.handleNodeUpdated(node, levelId)
|
||||
|
||||
@@ -119,7 +136,15 @@ function markNodesOverlappingSlab(
|
||||
// Only floor items are affected by slabs
|
||||
if (item.asset.attachTo) continue
|
||||
if (resolveLevelId(node, nodes) !== slabLevelId) continue
|
||||
if (itemOverlapsPolygon(item.position, getScaledDimensions(item), item.rotation, slab.polygon, 0.01)) {
|
||||
if (
|
||||
itemOverlapsPolygon(
|
||||
item.position,
|
||||
getScaledDimensions(item),
|
||||
item.rotation,
|
||||
slab.polygon,
|
||||
0.01,
|
||||
)
|
||||
) {
|
||||
markDirty(node.id)
|
||||
}
|
||||
} else if (node.type === 'wall') {
|
||||
|
||||
@@ -49,7 +49,13 @@ export function useSpatialQuery() {
|
||||
rotation: [number, number, number],
|
||||
ignoreIds?: string[],
|
||||
) => {
|
||||
return spatialGridManager.canPlaceOnCeiling(ceilingId, position, dimensions, rotation, ignoreIds)
|
||||
return spatialGridManager.canPlaceOnCeiling(
|
||||
ceilingId,
|
||||
position,
|
||||
dimensions,
|
||||
rotation,
|
||||
ignoreIds,
|
||||
)
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
@@ -140,7 +140,7 @@ export class WallSpatialGrid {
|
||||
|
||||
// Both are 'wall-side' - only conflict if they're on the same side
|
||||
// If either side is undefined, be conservative and assume conflict
|
||||
if (!newSide || !existing.side) {
|
||||
if (!(newSide && existing.side)) {
|
||||
return true
|
||||
}
|
||||
return newSide === existing.side
|
||||
|
||||
@@ -41,7 +41,11 @@ export {
|
||||
} from './lib/space-detection'
|
||||
// Schema
|
||||
export * from './schema'
|
||||
export { useInteractive, type ControlValue, type ItemInteractiveState } from './store/use-interactive'
|
||||
export {
|
||||
type ControlValue,
|
||||
type ItemInteractiveState,
|
||||
useInteractive,
|
||||
} from './store/use-interactive'
|
||||
export { default as useScene } from './store/use-scene'
|
||||
// Systems
|
||||
export { CeilingSystem } from './systems/ceiling/ceiling-system'
|
||||
|
||||
@@ -199,7 +199,7 @@ type WallSideUpdate = {
|
||||
export function detectSpacesForLevel(
|
||||
levelId: string,
|
||||
walls: WallNode[],
|
||||
gridResolution: number = 0.5, // Match spatial grid cell size
|
||||
gridResolution = 0.5, // Match spatial grid cell size
|
||||
): {
|
||||
wallUpdates: WallSideUpdate[]
|
||||
spaces: Space[]
|
||||
|
||||
@@ -1,25 +1,35 @@
|
||||
// Base
|
||||
export { BaseNode, generateId, Material, nodeType, objectId } from './base'
|
||||
// Collections
|
||||
export { generateCollectionId, type Collection, type CollectionId } from './collections'
|
||||
// Camera
|
||||
export { CameraSchema } from './camera'
|
||||
export type { AnimationEffect, Asset, AssetInput, Control, Effect, Interactive, LightEffect, SliderControl, TemperatureControl, ToggleControl } from './nodes/item'
|
||||
// Collections
|
||||
export { type Collection, type CollectionId, generateCollectionId } from './collections'
|
||||
export { BuildingNode } from './nodes/building'
|
||||
export { CeilingNode } from './nodes/ceiling'
|
||||
export { DoorNode, DoorSegment } from './nodes/door'
|
||||
export { GuideNode } from './nodes/guide'
|
||||
export type {
|
||||
AnimationEffect,
|
||||
Asset,
|
||||
AssetInput,
|
||||
Control,
|
||||
Effect,
|
||||
Interactive,
|
||||
LightEffect,
|
||||
SliderControl,
|
||||
TemperatureControl,
|
||||
ToggleControl,
|
||||
} from './nodes/item'
|
||||
export { getScaledDimensions, ItemNode } from './nodes/item'
|
||||
export { LevelNode } from './nodes/level'
|
||||
export { RoofNode } from './nodes/roof'
|
||||
export { ScanNode } from './nodes/scan'
|
||||
// Nodes
|
||||
export { SiteNode } from './nodes/site'
|
||||
export { SlabNode } from './nodes/slab'
|
||||
export { WallNode } from './nodes/wall'
|
||||
export { BuildingNode } from './nodes/building'
|
||||
export { CeilingNode } from './nodes/ceiling'
|
||||
|
||||
export { ZoneNode } from './nodes/zone'
|
||||
export { RoofNode } from './nodes/roof'
|
||||
export { ScanNode } from './nodes/scan'
|
||||
export { GuideNode } from './nodes/guide'
|
||||
export type { AnyNodeId, AnyNodeType } from './types'
|
||||
export { DoorNode, DoorSegment } from './nodes/door'
|
||||
export { WindowNode } from './nodes/window'
|
||||
export { ZoneNode } from './nodes/zone'
|
||||
export type { AnyNodeId, AnyNodeType } from './types'
|
||||
// Union types
|
||||
export { AnyNode } from './types'
|
||||
|
||||
@@ -11,7 +11,7 @@ export const DoorSegment = z.object({
|
||||
dividerThickness: z.number().default(0.03),
|
||||
|
||||
// panel-specific
|
||||
panelDepth: z.number().default(0.01), // + raised, - recessed
|
||||
panelDepth: z.number().default(0.01), // + raised, - recessed
|
||||
panelInset: z.number().default(0.04),
|
||||
})
|
||||
|
||||
@@ -42,8 +42,22 @@ export const DoorNode = BaseNode.extend({
|
||||
|
||||
// Leaf segments — stacked top to bottom, each with its own column split
|
||||
segments: z.array(DoorSegment).default([
|
||||
{ type: 'panel', heightRatio: 0.4, columnRatios: [1], dividerThickness: 0.03, panelDepth: 0.01, panelInset: 0.04 },
|
||||
{ type: 'panel', heightRatio: 0.6, columnRatios: [1], dividerThickness: 0.03, panelDepth: 0.01, panelInset: 0.04 },
|
||||
{
|
||||
type: 'panel',
|
||||
heightRatio: 0.4,
|
||||
columnRatios: [1],
|
||||
dividerThickness: 0.03,
|
||||
panelDepth: 0.01,
|
||||
panelInset: 0.04,
|
||||
},
|
||||
{
|
||||
type: 'panel',
|
||||
heightRatio: 0.6,
|
||||
columnRatios: [1],
|
||||
dividerThickness: 0.03,
|
||||
panelDepth: 0.01,
|
||||
panelInset: 0.04,
|
||||
},
|
||||
]),
|
||||
|
||||
// Handle
|
||||
@@ -58,7 +72,6 @@ export const DoorNode = BaseNode.extend({
|
||||
doorCloser: z.boolean().default(false),
|
||||
panicBar: z.boolean().default(false),
|
||||
panicBarHeight: z.number().default(1.0),
|
||||
|
||||
}).describe(dedent`Door node - a parametric door placed on a wall
|
||||
- position: center of the door in wall-local coordinate system (Y = height/2, always at floor)
|
||||
- segments: rows stacked top to bottom, each defining its own columnRatios
|
||||
|
||||
@@ -56,10 +56,7 @@ const lightEffectSchema = z.object({
|
||||
offset: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
|
||||
})
|
||||
|
||||
const effectSchema = z.discriminatedUnion('kind', [
|
||||
animationEffectSchema,
|
||||
lightEffectSchema,
|
||||
])
|
||||
const effectSchema = z.discriminatedUnion('kind', [animationEffectSchema, lightEffectSchema])
|
||||
|
||||
// --- Interactive descriptor ---
|
||||
|
||||
|
||||
@@ -12,7 +12,19 @@ import { ZoneNode } from './zone'
|
||||
export const LevelNode = BaseNode.extend({
|
||||
id: objectId('level'),
|
||||
type: nodeType('level'),
|
||||
children: z.array(z.union([WallNode.shape.id, ZoneNode.shape.id, SlabNode.shape.id, CeilingNode.shape.id, RoofNode.shape.id, ScanNode.shape.id, GuideNode.shape.id])).default([]),
|
||||
children: z
|
||||
.array(
|
||||
z.union([
|
||||
WallNode.shape.id,
|
||||
ZoneNode.shape.id,
|
||||
SlabNode.shape.id,
|
||||
CeilingNode.shape.id,
|
||||
RoofNode.shape.id,
|
||||
ScanNode.shape.id,
|
||||
GuideNode.shape.id,
|
||||
]),
|
||||
)
|
||||
.default([]),
|
||||
// Specific props
|
||||
level: z.number().default(0),
|
||||
}).describe(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import z from 'zod'
|
||||
import { BuildingNode } from './nodes/building'
|
||||
import { CeilingNode } from './nodes/ceiling'
|
||||
import { DoorNode } from './nodes/door'
|
||||
import { GuideNode } from './nodes/guide'
|
||||
import { ItemNode } from './nodes/item'
|
||||
import { LevelNode } from './nodes/level'
|
||||
@@ -8,7 +9,6 @@ import { RoofNode } from './nodes/roof'
|
||||
import { ScanNode } from './nodes/scan'
|
||||
import { SiteNode } from './nodes/site'
|
||||
import { SlabNode } from './nodes/slab'
|
||||
import { DoorNode } from './nodes/door'
|
||||
import { WallNode } from './nodes/wall'
|
||||
import { WindowNode } from './nodes/window'
|
||||
import { ZoneNode } from './nodes/zone'
|
||||
|
||||
@@ -164,7 +164,6 @@ export const deleteNodesAction = (
|
||||
return { nodes: nextNodes, rootNodeIds: nextRootIds, collections: nextCollections }
|
||||
})
|
||||
|
||||
|
||||
// Trigger a full scene re-validation after deleting node (as deleting a slab can cause widespread changes to level elevations)
|
||||
const currentNodes = get().nodes
|
||||
Object.values(currentNodes).forEach((node) => {
|
||||
|
||||
@@ -172,7 +172,8 @@ const useScene: UseSceneStore = create<SceneState>()(
|
||||
for (const nodeId of nodeIds) {
|
||||
const node = nextNodes[nodeId]
|
||||
if (!node) continue
|
||||
const existing = ('collectionIds' in node ? (node.collectionIds as CollectionId[]) : undefined) ?? []
|
||||
const existing =
|
||||
('collectionIds' in node ? (node.collectionIds as CollectionId[]) : undefined) ?? []
|
||||
nextNodes[nodeId] = { ...node, collectionIds: [...existing, id] } as AnyNode
|
||||
}
|
||||
return { collections: nextCollections, nodes: nextNodes }
|
||||
@@ -189,7 +190,7 @@ const useScene: UseSceneStore = create<SceneState>()(
|
||||
const nextNodes = { ...state.nodes }
|
||||
for (const nodeId of col?.nodeIds ?? []) {
|
||||
const node = nextNodes[nodeId]
|
||||
if (!node || !('collectionIds' in node)) continue
|
||||
if (!(node && 'collectionIds' in node)) continue
|
||||
nextNodes[nodeId] = {
|
||||
...node,
|
||||
collectionIds: (node.collectionIds as CollectionId[]).filter((cid) => cid !== id),
|
||||
@@ -217,7 +218,8 @@ const useScene: UseSceneStore = create<SceneState>()(
|
||||
}
|
||||
const node = state.nodes[nodeId]
|
||||
if (!node) return { collections: nextCollections }
|
||||
const existing = ('collectionIds' in node ? (node.collectionIds as CollectionId[]) : undefined) ?? []
|
||||
const existing =
|
||||
('collectionIds' in node ? (node.collectionIds as CollectionId[]) : undefined) ?? []
|
||||
const nextNodes = {
|
||||
...state.nodes,
|
||||
[nodeId]: { ...node, collectionIds: [...existing, id] } as AnyNode,
|
||||
@@ -235,7 +237,7 @@ const useScene: UseSceneStore = create<SceneState>()(
|
||||
[id]: { ...col, nodeIds: col.nodeIds.filter((n) => n !== nodeId) },
|
||||
}
|
||||
const node = state.nodes[nodeId]
|
||||
if (!node || !('collectionIds' in node)) return { collections: nextCollections }
|
||||
if (!(node && 'collectionIds' in node)) return { collections: nextCollections }
|
||||
const nextNodes = {
|
||||
...state.nodes,
|
||||
[nodeId]: {
|
||||
@@ -279,7 +281,10 @@ const useScene: UseSceneStore = create<SceneState>()(
|
||||
if (persisted.nodes) {
|
||||
for (const [id, node] of Object.entries(persisted.nodes)) {
|
||||
if (node.type === 'item' && !('scale' in node)) {
|
||||
persisted.nodes[id as AnyNodeId] = { ...(node as object), scale: [1, 1, 1] } as AnyNode
|
||||
persisted.nodes[id as AnyNodeId] = {
|
||||
...(node as object),
|
||||
scale: [1, 1, 1],
|
||||
} as AnyNode
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -308,8 +313,8 @@ const useScene: UseSceneStore = create<SceneState>()(
|
||||
|
||||
// Collect existing root nodes (should be BuildingNode or ItemNode)
|
||||
const existingRoots = (state.rootNodeIds || [])
|
||||
.map(id => state.nodes[id])
|
||||
.filter(node => node?.type === 'building' || node?.type === 'item')
|
||||
.map((id) => state.nodes[id])
|
||||
.filter((node) => node?.type === 'building' || node?.type === 'item')
|
||||
|
||||
// Create a new SiteNode with existing roots as children
|
||||
const site = SiteNode.parse({
|
||||
|
||||
@@ -58,8 +58,12 @@ export const DoorSystem = () => {
|
||||
function addBox(
|
||||
parent: THREE.Object3D,
|
||||
material: THREE.Material,
|
||||
w: number, h: number, d: number,
|
||||
x: number, y: number, z: number,
|
||||
w: number,
|
||||
h: number,
|
||||
d: number,
|
||||
x: number,
|
||||
y: number,
|
||||
z: number,
|
||||
) {
|
||||
const m = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), material)
|
||||
m.position.set(x, y, z)
|
||||
@@ -84,29 +88,77 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) {
|
||||
}
|
||||
|
||||
const {
|
||||
width, height, frameThickness, frameDepth, threshold, thresholdHeight,
|
||||
segments, handle, handleHeight, handleSide,
|
||||
doorCloser, panicBar, panicBarHeight, contentPadding, hingesSide,
|
||||
width,
|
||||
height,
|
||||
frameThickness,
|
||||
frameDepth,
|
||||
threshold,
|
||||
thresholdHeight,
|
||||
segments,
|
||||
handle,
|
||||
handleHeight,
|
||||
handleSide,
|
||||
doorCloser,
|
||||
panicBar,
|
||||
panicBarHeight,
|
||||
contentPadding,
|
||||
hingesSide,
|
||||
} = node
|
||||
|
||||
// Leaf occupies the full opening (no bottom frame bar — door opens to floor)
|
||||
const leafW = width - 2 * frameThickness
|
||||
const leafH = height - frameThickness // only top frame
|
||||
const leafH = height - frameThickness // only top frame
|
||||
const leafDepth = 0.04
|
||||
// Leaf center is shifted down from door center by half the top frame
|
||||
const leafCenterY = -frameThickness / 2
|
||||
|
||||
// ── Frame members ──
|
||||
// Left post — full height
|
||||
addBox(mesh, baseMaterial, frameThickness, height, frameDepth, -width / 2 + frameThickness / 2, 0, 0)
|
||||
addBox(
|
||||
mesh,
|
||||
baseMaterial,
|
||||
frameThickness,
|
||||
height,
|
||||
frameDepth,
|
||||
-width / 2 + frameThickness / 2,
|
||||
0,
|
||||
0,
|
||||
)
|
||||
// Right post — full height
|
||||
addBox(mesh, baseMaterial, frameThickness, height, frameDepth, width / 2 - frameThickness / 2, 0, 0)
|
||||
addBox(
|
||||
mesh,
|
||||
baseMaterial,
|
||||
frameThickness,
|
||||
height,
|
||||
frameDepth,
|
||||
width / 2 - frameThickness / 2,
|
||||
0,
|
||||
0,
|
||||
)
|
||||
// Head (top bar) — full width
|
||||
addBox(mesh, baseMaterial, width, frameThickness, frameDepth, 0, height / 2 - frameThickness / 2, 0)
|
||||
addBox(
|
||||
mesh,
|
||||
baseMaterial,
|
||||
width,
|
||||
frameThickness,
|
||||
frameDepth,
|
||||
0,
|
||||
height / 2 - frameThickness / 2,
|
||||
0,
|
||||
)
|
||||
|
||||
// ── Threshold (inside the frame) ──
|
||||
if (threshold) {
|
||||
addBox(mesh, baseMaterial, leafW, thresholdHeight, frameDepth, 0, -height / 2 + thresholdHeight / 2, 0)
|
||||
addBox(
|
||||
mesh,
|
||||
baseMaterial,
|
||||
leafW,
|
||||
thresholdHeight,
|
||||
frameDepth,
|
||||
0,
|
||||
-height / 2 + thresholdHeight / 2,
|
||||
0,
|
||||
)
|
||||
}
|
||||
|
||||
// ── Leaf — contentPadding border strips (no full backing; glass areas are open) ──
|
||||
@@ -142,7 +194,7 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) {
|
||||
const numCols = seg.columnRatios.length
|
||||
const colSum = seg.columnRatios.reduce((a, b) => a + b, 0)
|
||||
const usableW = contentW - (numCols - 1) * seg.dividerThickness
|
||||
const colWidths = seg.columnRatios.map(r => (r / colSum) * usableW)
|
||||
const colWidths = seg.columnRatios.map((r) => (r / colSum) * usableW)
|
||||
|
||||
// Column x-centers (relative to mesh center)
|
||||
const colXCenters: number[] = []
|
||||
@@ -157,7 +209,16 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) {
|
||||
cx = -contentW / 2
|
||||
for (let c = 0; c < numCols - 1; c++) {
|
||||
cx += colWidths[c]!
|
||||
addBox(mesh, baseMaterial, seg.dividerThickness, segH, leafDepth + 0.001, cx + seg.dividerThickness / 2, segCenterY, 0)
|
||||
addBox(
|
||||
mesh,
|
||||
baseMaterial,
|
||||
seg.dividerThickness,
|
||||
segH,
|
||||
leafDepth + 0.001,
|
||||
cx + seg.dividerThickness / 2,
|
||||
segCenterY,
|
||||
0,
|
||||
)
|
||||
cx += seg.dividerThickness
|
||||
}
|
||||
|
||||
@@ -198,9 +259,7 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) {
|
||||
const faceZ = leafDepth / 2
|
||||
|
||||
// X position: handleSide refers to which side the grip is on
|
||||
const handleX = handleSide === 'right'
|
||||
? leafW / 2 - 0.045
|
||||
: -leafW / 2 + 0.045
|
||||
const handleX = handleSide === 'right' ? leafW / 2 - 0.045 : -leafW / 2 + 0.045
|
||||
|
||||
// Backplate
|
||||
addBox(mesh, baseMaterial, 0.028, 0.14, 0.01, handleX, handleY, faceZ + 0.005)
|
||||
@@ -214,7 +273,16 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) {
|
||||
// Body
|
||||
addBox(mesh, baseMaterial, 0.28, 0.055, 0.055, 0, closerY, leafDepth / 2 + 0.03)
|
||||
// Arm (simplified as thin bar to frame side)
|
||||
addBox(mesh, baseMaterial, 0.14, 0.015, 0.015, leafW / 4, closerY + 0.025, leafDepth / 2 + 0.015)
|
||||
addBox(
|
||||
mesh,
|
||||
baseMaterial,
|
||||
0.14,
|
||||
0.015,
|
||||
0.015,
|
||||
leafW / 4,
|
||||
closerY + 0.025,
|
||||
leafDepth / 2 + 0.015,
|
||||
)
|
||||
}
|
||||
|
||||
// ── Panic bar ──
|
||||
@@ -225,9 +293,7 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) {
|
||||
|
||||
// ── Hinges (3 knuckle-style hinges on the hinge side) ──
|
||||
{
|
||||
const hingeX = hingesSide === 'right'
|
||||
? leafW / 2 - 0.012
|
||||
: -leafW / 2 + 0.012
|
||||
const hingeX = hingesSide === 'right' ? leafW / 2 - 0.012 : -leafW / 2 + 0.012
|
||||
const hingeZ = 0 // centered in leaf depth
|
||||
const hingeH = 0.1
|
||||
const hingeW = 0.024
|
||||
|
||||
@@ -74,8 +74,8 @@ function outsetPolygon(polygon: Array<[number, number]>, amount: number): Array<
|
||||
offEdges.push([polygon[i]![0], polygon[i]![1], dx, dz])
|
||||
continue
|
||||
}
|
||||
const nx = (s * dz / len) * amount
|
||||
const nz = (s * -dx / len) * amount
|
||||
const nx = ((s * dz) / len) * amount
|
||||
const nz = ((s * -dx) / len) * amount
|
||||
offEdges.push([polygon[i]![0] + nx, polygon[i]![1] + nz, dx, dz])
|
||||
}
|
||||
|
||||
|
||||
@@ -321,10 +321,10 @@ function collectCutoutBrushes(
|
||||
|
||||
// Calculate bounds in wall-local space
|
||||
const v3 = new THREE.Vector3()
|
||||
let minX = Infinity,
|
||||
maxX = -Infinity
|
||||
let minY = Infinity,
|
||||
maxY = -Infinity
|
||||
let minX = Number.POSITIVE_INFINITY,
|
||||
maxX = Number.NEGATIVE_INFINITY
|
||||
let minY = Number.POSITIVE_INFINITY,
|
||||
maxY = Number.NEGATIVE_INFINITY
|
||||
|
||||
for (let i = 0; i < positions.count; i++) {
|
||||
v3.fromBufferAttribute(positions, i)
|
||||
|
||||
@@ -58,8 +58,12 @@ export const WindowSystem = () => {
|
||||
function addBox(
|
||||
parent: THREE.Object3D,
|
||||
material: THREE.Material,
|
||||
w: number, h: number, d: number,
|
||||
x: number, y: number, z: number,
|
||||
w: number,
|
||||
h: number,
|
||||
d: number,
|
||||
x: number,
|
||||
y: number,
|
||||
z: number,
|
||||
) {
|
||||
const m = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), material)
|
||||
m.position.set(x, y, z)
|
||||
@@ -84,9 +88,17 @@ function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh) {
|
||||
}
|
||||
|
||||
const {
|
||||
width, height, frameDepth, frameThickness,
|
||||
columnRatios, rowRatios, columnDividerThickness, rowDividerThickness,
|
||||
sill, sillDepth, sillThickness,
|
||||
width,
|
||||
height,
|
||||
frameDepth,
|
||||
frameThickness,
|
||||
columnRatios,
|
||||
rowRatios,
|
||||
columnDividerThickness,
|
||||
rowDividerThickness,
|
||||
sill,
|
||||
sillDepth,
|
||||
sillThickness,
|
||||
} = node
|
||||
|
||||
const innerW = width - 2 * frameThickness
|
||||
@@ -94,11 +106,47 @@ function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh) {
|
||||
|
||||
// ── Frame members ──
|
||||
// Top / bottom — full width
|
||||
addBox(mesh, frameMaterial, width, frameThickness, frameDepth, 0, height / 2 - frameThickness / 2, 0)
|
||||
addBox(mesh, frameMaterial, width, frameThickness, frameDepth, 0, -height / 2 + frameThickness / 2, 0)
|
||||
addBox(
|
||||
mesh,
|
||||
frameMaterial,
|
||||
width,
|
||||
frameThickness,
|
||||
frameDepth,
|
||||
0,
|
||||
height / 2 - frameThickness / 2,
|
||||
0,
|
||||
)
|
||||
addBox(
|
||||
mesh,
|
||||
frameMaterial,
|
||||
width,
|
||||
frameThickness,
|
||||
frameDepth,
|
||||
0,
|
||||
-height / 2 + frameThickness / 2,
|
||||
0,
|
||||
)
|
||||
// Left / right — inner height to avoid corner overlap
|
||||
addBox(mesh, frameMaterial, frameThickness, innerH, frameDepth, -width / 2 + frameThickness / 2, 0, 0)
|
||||
addBox(mesh, frameMaterial, frameThickness, innerH, frameDepth, width / 2 - frameThickness / 2, 0, 0)
|
||||
addBox(
|
||||
mesh,
|
||||
frameMaterial,
|
||||
frameThickness,
|
||||
innerH,
|
||||
frameDepth,
|
||||
-width / 2 + frameThickness / 2,
|
||||
0,
|
||||
0,
|
||||
)
|
||||
addBox(
|
||||
mesh,
|
||||
frameMaterial,
|
||||
frameThickness,
|
||||
innerH,
|
||||
frameDepth,
|
||||
width / 2 - frameThickness / 2,
|
||||
0,
|
||||
0,
|
||||
)
|
||||
|
||||
// ── Pane grid ──
|
||||
const numCols = columnRatios.length
|
||||
@@ -109,8 +157,8 @@ function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh) {
|
||||
|
||||
const colSum = columnRatios.reduce((a, b) => a + b, 0)
|
||||
const rowSum = rowRatios.reduce((a, b) => a + b, 0)
|
||||
const colWidths = columnRatios.map(r => (r / colSum) * usableW)
|
||||
const rowHeights = rowRatios.map(r => (r / rowSum) * usableH)
|
||||
const colWidths = columnRatios.map((r) => (r / colSum) * usableW)
|
||||
const rowHeights = rowRatios.map((r) => (r / rowSum) * usableH)
|
||||
|
||||
// Compute column x-centers starting from left edge of inner area
|
||||
const colXCenters: number[] = []
|
||||
@@ -134,7 +182,16 @@ function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh) {
|
||||
cx = -innerW / 2
|
||||
for (let c = 0; c < numCols - 1; c++) {
|
||||
cx += colWidths[c]!
|
||||
addBox(mesh, frameMaterial, columnDividerThickness, innerH, frameDepth, cx + columnDividerThickness / 2, 0, 0)
|
||||
addBox(
|
||||
mesh,
|
||||
frameMaterial,
|
||||
columnDividerThickness,
|
||||
innerH,
|
||||
frameDepth,
|
||||
cx + columnDividerThickness / 2,
|
||||
0,
|
||||
0,
|
||||
)
|
||||
cx += columnDividerThickness
|
||||
}
|
||||
|
||||
@@ -144,7 +201,16 @@ function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh) {
|
||||
cy -= rowHeights[r]!
|
||||
const divY = cy - rowDividerThickness / 2
|
||||
for (let c = 0; c < numCols; c++) {
|
||||
addBox(mesh, frameMaterial, colWidths[c]!, rowDividerThickness, frameDepth, colXCenters[c]!, divY, 0)
|
||||
addBox(
|
||||
mesh,
|
||||
frameMaterial,
|
||||
colWidths[c]!,
|
||||
rowDividerThickness,
|
||||
frameDepth,
|
||||
colXCenters[c]!,
|
||||
divY,
|
||||
0,
|
||||
)
|
||||
}
|
||||
cy -= rowDividerThickness
|
||||
}
|
||||
@@ -153,7 +219,16 @@ function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh) {
|
||||
const glassDepth = Math.max(0.004, frameDepth * 0.08)
|
||||
for (let c = 0; c < numCols; c++) {
|
||||
for (let r = 0; r < numRows; r++) {
|
||||
addBox(mesh, glassMaterial, colWidths[c]!, rowHeights[r]!, glassDepth, colXCenters[c]!, rowYCenters[r]!, 0)
|
||||
addBox(
|
||||
mesh,
|
||||
glassMaterial,
|
||||
colWidths[c]!,
|
||||
rowHeights[r]!,
|
||||
glassDepth,
|
||||
colXCenters[c]!,
|
||||
rowYCenters[r]!,
|
||||
0,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,7 +237,16 @@ function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh) {
|
||||
const sillW = width + sillDepth * 0.4 // slightly wider than frame
|
||||
// Protrudes from the front face of the frame (+Z)
|
||||
const sillZ = frameDepth / 2 + sillDepth / 2
|
||||
addBox(mesh, frameMaterial, sillW, sillThickness, sillDepth, 0, -height / 2 - sillThickness / 2, sillZ)
|
||||
addBox(
|
||||
mesh,
|
||||
frameMaterial,
|
||||
sillW,
|
||||
sillThickness,
|
||||
sillDepth,
|
||||
0,
|
||||
-height / 2 - sillThickness / 2,
|
||||
sillZ,
|
||||
)
|
||||
}
|
||||
|
||||
// ── Cutout (for wall CSG) — always full window dimensions, 1m deep ──
|
||||
|
||||
@@ -4,4 +4,4 @@
|
||||
*/
|
||||
export const isObject = (val: unknown): val is Record<string, any> => {
|
||||
return val !== null && typeof val === 'object' && !Array.isArray(val)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
{
|
||||
"extends": "@repo/typescript-config/react-library.json",
|
||||
"extends": "@pascal/typescript-config/react-library.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"noEmit": false,
|
||||
"composite": true,
|
||||
"incremental": true
|
||||
},
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
"cmdk": "^1.1.1",
|
||||
"howler": "^2.2.4",
|
||||
"lucide-react": "^0.562.0",
|
||||
"mitt": "^3.0.1",
|
||||
"motion": "^12.34.3",
|
||||
"nanoid": "^5.1.6",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
@@ -49,14 +50,11 @@
|
||||
"devDependencies": {
|
||||
"@pascal-app/core": "*",
|
||||
"@pascal-app/viewer": "*",
|
||||
"@react-three/drei": "^10.7.7",
|
||||
"@react-three/fiber": "^9.5.0",
|
||||
"@repo/typescript-config": "*",
|
||||
"@pascal/typescript-config": "*",
|
||||
"@types/howler": "^2.2.12",
|
||||
"@types/react": "19.2.2",
|
||||
"@types/react-dom": "19.2.2",
|
||||
"@types/three": "^0.183.1",
|
||||
"react": "^19.2.4",
|
||||
"three": "^0.183.1",
|
||||
"typescript": "5.9.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,9 +61,7 @@ export const CustomCameraControls = () => {
|
||||
: CameraControlsImpl.ACTION.DOLLY
|
||||
|
||||
return {
|
||||
left: isPreviewMode
|
||||
? CameraControlsImpl.ACTION.SCREEN_PAN
|
||||
: CameraControlsImpl.ACTION.NONE,
|
||||
left: isPreviewMode ? CameraControlsImpl.ACTION.SCREEN_PAN : CameraControlsImpl.ACTION.NONE,
|
||||
middle: CameraControlsImpl.ACTION.SCREEN_PAN,
|
||||
right: CameraControlsImpl.ACTION.ROTATE,
|
||||
wheel: wheelAction,
|
||||
@@ -159,7 +157,7 @@ export const CustomCameraControls = () => {
|
||||
: null
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPreviewMode || !controls.current) return
|
||||
if (!(isPreviewMode && controls.current)) return
|
||||
|
||||
const nodes = useScene.getState().nodes
|
||||
let node = previewTargetNodeId ? nodes[previewTargetNodeId] : null
|
||||
@@ -176,8 +174,12 @@ export const CustomCameraControls = () => {
|
||||
requestAnimationFrame(() => {
|
||||
if (!controls.current) return
|
||||
controls.current.setLookAt(
|
||||
position[0], position[1], position[2],
|
||||
target[0], target[1], target[2],
|
||||
position[0],
|
||||
position[1],
|
||||
position[2],
|
||||
target[0],
|
||||
target[1],
|
||||
target[2],
|
||||
true,
|
||||
)
|
||||
})
|
||||
@@ -231,7 +233,7 @@ export const CustomCameraControls = () => {
|
||||
if (!controls.current) return
|
||||
|
||||
const node = useScene.getState().nodes[nodeId]
|
||||
if (!node || !node.camera) return
|
||||
if (!(node && node.camera)) return
|
||||
const { position, target } = node.camera
|
||||
|
||||
controls.current.setLookAt(
|
||||
@@ -311,11 +313,11 @@ export const CustomCameraControls = () => {
|
||||
maxPolarAngle={Math.PI / 2 - 0.1}
|
||||
minDistance={10}
|
||||
minPolarAngle={0}
|
||||
ref={controls}
|
||||
mouseButtons={mouseButtons}
|
||||
onTransitionStart={onTransitionStart}
|
||||
onRest={onRest}
|
||||
onSleep={onRest}
|
||||
onTransitionStart={onTransitionStart}
|
||||
ref={controls}
|
||||
restThreshold={0.01}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -38,7 +38,7 @@ export function ExportManager() {
|
||||
console.error('Export error:', error)
|
||||
reject(error)
|
||||
},
|
||||
{ binary: true }
|
||||
{ binary: true },
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ export function FloatingActionMenu() {
|
||||
const isValidType = node ? ALLOWED_TYPES.includes(node.type) : false
|
||||
|
||||
useFrame(() => {
|
||||
if (!selectedId || !isValidType || !groupRef.current) return
|
||||
if (!(selectedId && isValidType && groupRef.current)) return
|
||||
|
||||
const obj = sceneRegistry.nodes.get(selectedId)
|
||||
if (obj) {
|
||||
@@ -65,7 +65,7 @@ export function FloatingActionMenu() {
|
||||
const handleDuplicate = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
if (!node || !node.parentId) return
|
||||
if (!(node && node.parentId)) return
|
||||
sfxEmitter.emit('sfx:item-pick')
|
||||
useScene.temporal.getState().pause()
|
||||
|
||||
@@ -103,7 +103,7 @@ export function FloatingActionMenu() {
|
||||
const handleDelete = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
if (!selectedId || !node) return
|
||||
if (!(selectedId && node)) return
|
||||
sfxEmitter.emit('sfx:item-delete')
|
||||
deleteNode(selectedId as AnyNodeId)
|
||||
if (node.parentId) useScene.getState().dirtyNodes.add(node.parentId as AnyNodeId)
|
||||
@@ -112,43 +112,43 @@ export function FloatingActionMenu() {
|
||||
[selectedId, node, deleteNode, setSelection],
|
||||
)
|
||||
|
||||
if (!selectedId || !node || !isValidType) return null
|
||||
if (!(selectedId && node && isValidType)) return null
|
||||
|
||||
return (
|
||||
<group ref={groupRef}>
|
||||
<Html
|
||||
center
|
||||
zIndexRange={[100, 0]}
|
||||
style={{
|
||||
pointerEvents: 'auto',
|
||||
touchAction: 'none',
|
||||
}}
|
||||
zIndexRange={[100, 0]}
|
||||
>
|
||||
<div
|
||||
className="flex items-center gap-1 p-1 rounded-lg border border-border bg-background/95 shadow-xl backdrop-blur-md"
|
||||
className="flex items-center gap-1 rounded-lg border border-border bg-background/95 p-1 shadow-xl backdrop-blur-md"
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onPointerUp={(e) => e.stopPropagation()}
|
||||
>
|
||||
<button
|
||||
className="tooltip-trigger rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
onClick={handleMove}
|
||||
className="p-1.5 rounded-md hover:bg-accent text-muted-foreground hover:text-foreground transition-colors tooltip-trigger"
|
||||
title="Move"
|
||||
>
|
||||
<Move className="w-4 h-4" />
|
||||
<Move className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
className="tooltip-trigger rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
onClick={handleDuplicate}
|
||||
className="p-1.5 rounded-md hover:bg-accent text-muted-foreground hover:text-foreground transition-colors tooltip-trigger"
|
||||
title="Duplicate"
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
<Copy className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
className="tooltip-trigger rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
|
||||
onClick={handleDelete}
|
||||
className="p-1.5 rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors tooltip-trigger"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</Html>
|
||||
|
||||
@@ -32,11 +32,11 @@ export const Grid = ({
|
||||
revealRadius?: number
|
||||
}) => {
|
||||
const theme = useViewer((state) => state.theme)
|
||||
|
||||
|
||||
// Use slightly lighter colors for dark mode grid to make it apparent
|
||||
const effectiveCellColor = theme === 'dark' ? '#555566' : cellColor
|
||||
const effectiveSectionColor = theme === 'dark' ? '#666677' : sectionColor
|
||||
|
||||
|
||||
const cursorPositionRef = useRef(new Vector2(0, 0))
|
||||
|
||||
const material = useMemo(() => {
|
||||
@@ -90,7 +90,7 @@ export const Grid = ({
|
||||
|
||||
// Baseline alpha: small amount of opacity everywhere the grid exists
|
||||
const baseAlpha = float(0.4) // Subtle global visibility
|
||||
|
||||
|
||||
// Combined alpha with cursor fade and baseline minimum
|
||||
const alpha = g1.add(g2).mul(fade).mul(cursorFade.max(baseAlpha))
|
||||
const finalAlpha = mix(alpha.mul(0.75), alpha, g2)
|
||||
@@ -102,15 +102,15 @@ export const Grid = ({
|
||||
depthWrite: false,
|
||||
})
|
||||
}, [
|
||||
cellSize,
|
||||
cellThickness,
|
||||
effectiveCellColor,
|
||||
sectionSize,
|
||||
sectionThickness,
|
||||
effectiveSectionColor,
|
||||
fadeDistance,
|
||||
fadeStrength,
|
||||
revealRadius
|
||||
cellSize,
|
||||
cellThickness,
|
||||
effectiveCellColor,
|
||||
sectionSize,
|
||||
sectionThickness,
|
||||
effectiveSectionColor,
|
||||
fadeDistance,
|
||||
fadeStrength,
|
||||
revealRadius,
|
||||
])
|
||||
|
||||
const gridRef = useRef<Mesh>(null!)
|
||||
@@ -148,7 +148,13 @@ export const Grid = ({
|
||||
const showGrid = useViewer((state) => state.showGrid)
|
||||
|
||||
return (
|
||||
<mesh rotation-x={-Math.PI / 2} material={material} ref={gridRef} visible={showGrid} layers={EDITOR_LAYER}>
|
||||
<mesh
|
||||
layers={EDITOR_LAYER}
|
||||
material={material}
|
||||
ref={gridRef}
|
||||
rotation-x={-Math.PI / 2}
|
||||
visible={showGrid}
|
||||
>
|
||||
<planeGeometry args={[fadeDistance * 2, fadeDistance * 2]} />
|
||||
</mesh>
|
||||
)
|
||||
|
||||
@@ -3,13 +3,18 @@
|
||||
import { initSpaceDetectionSync, initSpatialGridSync, useScene } from '@pascal-app/core'
|
||||
import { InteractiveSystem, useViewer, Viewer } from '@pascal-app/viewer'
|
||||
import { type ReactNode, useEffect, useState } from 'react'
|
||||
import { useAutoSave, type SaveStatus } from '../../hooks/use-auto-save'
|
||||
import { applySceneGraphToEditor, loadSceneFromLocalStorage, type SceneGraph } from '../../lib/scene'
|
||||
import { useKeyboard } from '../../hooks/use-keyboard'
|
||||
import { initSFXBus } from '../../lib/sfx-bus'
|
||||
import useEditor from '../../store/use-editor'
|
||||
import { ViewerOverlay } from '../../components/viewer-overlay'
|
||||
import { ViewerZoneSystem } from '../../components/viewer-zone-system'
|
||||
import { type PresetsAdapter, PresetsProvider } from '../../contexts/presets-context'
|
||||
import { type SaveStatus, useAutoSave } from '../../hooks/use-auto-save'
|
||||
import { useKeyboard } from '../../hooks/use-keyboard'
|
||||
import {
|
||||
applySceneGraphToEditor,
|
||||
loadSceneFromLocalStorage,
|
||||
type SceneGraph,
|
||||
} from '../../lib/scene'
|
||||
import { initSFXBus } from '../../lib/sfx-bus'
|
||||
import useEditor from '../../store/use-editor'
|
||||
import { CeilingSystem } from '../systems/ceiling/ceiling-system'
|
||||
import { ZoneLabelEditorSystem } from '../systems/zone/zone-label-editor-system'
|
||||
import { ZoneSystem } from '../systems/zone/zone-system'
|
||||
@@ -23,7 +28,6 @@ import { SceneLoader } from '../ui/scene-loader'
|
||||
import { AppSidebar } from '../ui/sidebar/app-sidebar'
|
||||
import type { SettingsPanelProps } from '../ui/sidebar/panels/settings-panel'
|
||||
import type { SitePanelProps } from '../ui/sidebar/panels/site-panel'
|
||||
import { PresetsProvider, type PresetsAdapter } from '../../contexts/presets-context'
|
||||
import { CustomCameraControls } from './custom-camera-controls'
|
||||
import { ExportManager } from './export-manager'
|
||||
import { FloatingActionMenu } from './floating-action-menu'
|
||||
@@ -97,20 +101,20 @@ function EditorSceneCrashFallback() {
|
||||
return (
|
||||
<div className="fixed inset-0 z-[80] flex items-center justify-center bg-background/95 p-4 text-foreground">
|
||||
<div className="w-full max-w-md rounded-2xl border border-border/60 bg-background p-6 shadow-xl">
|
||||
<h2 className="text-lg font-semibold">The editor scene failed to render</h2>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
<h2 className="font-semibold text-lg">The editor scene failed to render</h2>
|
||||
<p className="mt-2 text-muted-foreground text-sm">
|
||||
You can retry the scene or return home without reloading the whole app shell.
|
||||
</p>
|
||||
<div className="mt-4 flex items-center gap-2">
|
||||
<button
|
||||
className="rounded-md border border-border bg-accent px-3 py-2 text-sm font-medium hover:bg-accent/80"
|
||||
className="rounded-md border border-border bg-accent px-3 py-2 font-medium text-sm hover:bg-accent/80"
|
||||
onClick={() => window.location.reload()}
|
||||
type="button"
|
||||
>
|
||||
Reload editor
|
||||
</button>
|
||||
<a
|
||||
className="rounded-md border border-border bg-background px-3 py-2 text-sm font-medium hover:bg-accent/40"
|
||||
className="rounded-md border border-border bg-background px-3 py-2 font-medium text-sm hover:bg-accent/40"
|
||||
href="/"
|
||||
>
|
||||
Back to home
|
||||
@@ -175,7 +179,9 @@ export default function Editor({
|
||||
|
||||
load()
|
||||
|
||||
return () => { cancelled = true }
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [onLoad, isLoadingSceneRef])
|
||||
|
||||
// Apply preview scene when version preview mode changes
|
||||
@@ -196,45 +202,46 @@ export default function Editor({
|
||||
|
||||
return (
|
||||
<PresetsProvider adapter={presetsAdapter}>
|
||||
<div className="w-full h-full dark text-foreground">
|
||||
{showLoader && <SceneLoader />}
|
||||
<div className="dark h-full w-full text-foreground">
|
||||
{showLoader && <SceneLoader />}
|
||||
|
||||
{isPreviewMode ? (
|
||||
<ViewerOverlay
|
||||
onBack={() => useEditor.getState().setPreviewMode(false)}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<ActionMenu />
|
||||
<PanelManager />
|
||||
<HelperManager />
|
||||
{isPreviewMode ? (
|
||||
<ViewerOverlay onBack={() => useEditor.getState().setPreviewMode(false)} />
|
||||
) : (
|
||||
<>
|
||||
<ActionMenu />
|
||||
<PanelManager />
|
||||
<HelperManager />
|
||||
|
||||
<SidebarProvider className="fixed z-20">
|
||||
<AppSidebar appMenuButton={appMenuButton} sidebarTop={sidebarTop} settingsPanelProps={settingsPanelProps} sitePanelProps={sitePanelProps} />
|
||||
</SidebarProvider>
|
||||
</>
|
||||
)}
|
||||
<SidebarProvider className="fixed z-20">
|
||||
<AppSidebar
|
||||
appMenuButton={appMenuButton}
|
||||
settingsPanelProps={settingsPanelProps}
|
||||
sidebarTop={sidebarTop}
|
||||
sitePanelProps={sitePanelProps}
|
||||
/>
|
||||
</SidebarProvider>
|
||||
</>
|
||||
)}
|
||||
|
||||
<ErrorBoundary fallback={<EditorSceneCrashFallback />}>
|
||||
<Viewer selectionManager={isPreviewMode ? 'default' : 'custom'}>
|
||||
{!isPreviewMode && <SelectionManager />}
|
||||
{!isPreviewMode && <FloatingActionMenu />}
|
||||
<ExportManager />
|
||||
{isPreviewMode ? <ViewerZoneSystem /> : <ZoneSystem />}
|
||||
<CeilingSystem />
|
||||
{!isPreviewMode && (
|
||||
<Grid cellColor="#aaa" sectionColor="#ccc" fadeDistance={500} />
|
||||
)}
|
||||
{!isPreviewMode && <ToolManager />}
|
||||
<CustomCameraControls />
|
||||
<ThumbnailGenerator onThumbnailCapture={onThumbnailCapture} />
|
||||
<PresetThumbnailGenerator />
|
||||
{!isPreviewMode && <SiteEdgeLabels />}
|
||||
{isPreviewMode && <InteractiveSystem />}
|
||||
</Viewer>
|
||||
{!isPreviewMode && <ZoneLabelEditorSystem />}
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
<ErrorBoundary fallback={<EditorSceneCrashFallback />}>
|
||||
<Viewer selectionManager={isPreviewMode ? 'default' : 'custom'}>
|
||||
{!isPreviewMode && <SelectionManager />}
|
||||
{!isPreviewMode && <FloatingActionMenu />}
|
||||
<ExportManager />
|
||||
{isPreviewMode ? <ViewerZoneSystem /> : <ZoneSystem />}
|
||||
<CeilingSystem />
|
||||
{!isPreviewMode && <Grid cellColor="#aaa" fadeDistance={500} sectionColor="#ccc" />}
|
||||
{!isPreviewMode && <ToolManager />}
|
||||
<CustomCameraControls />
|
||||
<ThumbnailGenerator onThumbnailCapture={onThumbnailCapture} />
|
||||
<PresetThumbnailGenerator />
|
||||
{!isPreviewMode && <SiteEdgeLabels />}
|
||||
{isPreviewMode && <InteractiveSystem />}
|
||||
</Viewer>
|
||||
{!isPreviewMode && <ZoneLabelEditorSystem />}
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
</PresetsProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -31,7 +31,10 @@ export const PresetThumbnailGenerator = () => {
|
||||
|
||||
const clones: THREE.Object3D[] = []
|
||||
target.traverse((obj) => {
|
||||
if (!(obj instanceof THREE.Mesh || obj instanceof THREE.Line || obj instanceof THREE.Points)) return
|
||||
if (
|
||||
!(obj instanceof THREE.Mesh || obj instanceof THREE.Line || obj instanceof THREE.Points)
|
||||
)
|
||||
return
|
||||
const c = obj.clone(false) // shallow clone: copies geometry, material, visible — no children
|
||||
relMatrix.multiplyMatrices(targetInverse, obj.matrixWorld)
|
||||
relMatrix.decompose(c.position, c.quaternion, c.scale)
|
||||
@@ -72,7 +75,10 @@ export const PresetThumbnailGenerator = () => {
|
||||
const snapshot = new Map<THREE.Object3D, boolean>()
|
||||
scene.traverse((obj) => {
|
||||
if (cloneSet.has(obj)) return
|
||||
if (!(obj instanceof THREE.Mesh || obj instanceof THREE.Line || obj instanceof THREE.Points)) return
|
||||
if (
|
||||
!(obj instanceof THREE.Mesh || obj instanceof THREE.Line || obj instanceof THREE.Points)
|
||||
)
|
||||
return
|
||||
snapshot.set(obj, obj.visible)
|
||||
obj.visible = false
|
||||
})
|
||||
|
||||
@@ -7,443 +7,483 @@ import {
|
||||
resolveLevelId,
|
||||
sceneRegistry,
|
||||
useScene,
|
||||
} from "@pascal-app/core";
|
||||
} from '@pascal-app/core'
|
||||
|
||||
import { useViewer } from "@pascal-app/viewer";
|
||||
import { useEffect, useRef } from "react";
|
||||
import useEditor from "./../../store/use-editor";
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import useEditor from './../../store/use-editor'
|
||||
|
||||
const isNodeInCurrentLevel = (node: AnyNode): boolean => {
|
||||
const currentLevelId = useViewer.getState().selection.levelId;
|
||||
if (!currentLevelId) return true; // No level selected, allow all
|
||||
const nodeLevelId = resolveLevelId(node, useScene.getState().nodes);
|
||||
return nodeLevelId === currentLevelId;
|
||||
};
|
||||
|
||||
type SelectableNodeType = "wall" | "item" | "building" | "zone" | 'slab' | 'ceiling' | 'roof' | 'window' | 'door';
|
||||
|
||||
type ModifierKeys = {
|
||||
meta: boolean;
|
||||
ctrl: boolean;
|
||||
};
|
||||
|
||||
interface SelectionStrategy {
|
||||
types: SelectableNodeType[];
|
||||
handleSelect: (node: AnyNode, nativeEvent?: any, modifierKeys?: ModifierKeys) => void;
|
||||
handleDeselect: () => void;
|
||||
isValid: (node: AnyNode) => boolean;
|
||||
const currentLevelId = useViewer.getState().selection.levelId
|
||||
if (!currentLevelId) return true // No level selected, allow all
|
||||
const nodeLevelId = resolveLevelId(node, useScene.getState().nodes)
|
||||
return nodeLevelId === currentLevelId
|
||||
}
|
||||
|
||||
export const resolveBuildingId = (levelId: string, nodes: Record<string, AnyNode>): string | null => {
|
||||
const level = nodes[levelId];
|
||||
if (!level) return null;
|
||||
if (level.parentId && nodes[level.parentId]?.type === "building") {
|
||||
return level.parentId;
|
||||
type SelectableNodeType =
|
||||
| 'wall'
|
||||
| 'item'
|
||||
| 'building'
|
||||
| 'zone'
|
||||
| 'slab'
|
||||
| 'ceiling'
|
||||
| 'roof'
|
||||
| 'window'
|
||||
| 'door'
|
||||
|
||||
type ModifierKeys = {
|
||||
meta: boolean
|
||||
ctrl: boolean
|
||||
}
|
||||
|
||||
interface SelectionStrategy {
|
||||
types: SelectableNodeType[]
|
||||
handleSelect: (node: AnyNode, nativeEvent?: any, modifierKeys?: ModifierKeys) => void
|
||||
handleDeselect: () => void
|
||||
isValid: (node: AnyNode) => boolean
|
||||
}
|
||||
|
||||
export const resolveBuildingId = (
|
||||
levelId: string,
|
||||
nodes: Record<string, AnyNode>,
|
||||
): string | null => {
|
||||
const level = nodes[levelId]
|
||||
if (!level) return null
|
||||
if (level.parentId && nodes[level.parentId]?.type === 'building') {
|
||||
return level.parentId
|
||||
}
|
||||
return null;
|
||||
};
|
||||
return null
|
||||
}
|
||||
|
||||
const computeNextIds = (
|
||||
node: AnyNode,
|
||||
selectedIds: string[],
|
||||
event?: any,
|
||||
modifierKeys?: ModifierKeys
|
||||
modifierKeys?: ModifierKeys,
|
||||
): string[] => {
|
||||
const isMeta = event?.metaKey || event?.nativeEvent?.metaKey || modifierKeys?.meta || false;
|
||||
const isCtrl = event?.ctrlKey || event?.nativeEvent?.ctrlKey || modifierKeys?.ctrl || false;
|
||||
const isMeta = event?.metaKey || event?.nativeEvent?.metaKey || modifierKeys?.meta
|
||||
const isCtrl = event?.ctrlKey || event?.nativeEvent?.ctrlKey || modifierKeys?.ctrl
|
||||
|
||||
console.log("computeNextIds:", {
|
||||
console.log('computeNextIds:', {
|
||||
nodeId: node.id,
|
||||
selectedIds,
|
||||
isMeta,
|
||||
isCtrl,
|
||||
eventMeta: event?.metaKey,
|
||||
nativeMeta: event?.nativeEvent?.metaKey,
|
||||
modMeta: modifierKeys?.meta
|
||||
});
|
||||
modMeta: modifierKeys?.meta,
|
||||
})
|
||||
|
||||
if (isMeta || isCtrl) {
|
||||
if (selectedIds.includes(node.id)) {
|
||||
return selectedIds.filter((id) => id !== node.id);
|
||||
} else {
|
||||
return [...selectedIds, node.id];
|
||||
return selectedIds.filter((id) => id !== node.id)
|
||||
}
|
||||
return [...selectedIds, node.id]
|
||||
}
|
||||
|
||||
// Not holding modifiers: select only this node
|
||||
return [node.id];
|
||||
};
|
||||
return [node.id]
|
||||
}
|
||||
|
||||
const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
|
||||
site: {
|
||||
types: ["building"],
|
||||
types: ['building'],
|
||||
handleSelect: (node) => {
|
||||
useViewer
|
||||
.getState()
|
||||
.setSelection({ buildingId: (node as BuildingNode).id });
|
||||
useViewer.getState().setSelection({ buildingId: (node as BuildingNode).id })
|
||||
},
|
||||
handleDeselect: () => {
|
||||
useViewer.getState().setSelection({ buildingId: null });
|
||||
useViewer.getState().setSelection({ buildingId: null })
|
||||
},
|
||||
isValid: (node) => node.type === "building",
|
||||
isValid: (node) => node.type === 'building',
|
||||
},
|
||||
|
||||
structure: {
|
||||
types: ["wall", "item", "zone", "slab", "ceiling", "roof", "window", "door"],
|
||||
types: ['wall', 'item', 'zone', 'slab', 'ceiling', 'roof', 'window', 'door'],
|
||||
handleSelect: (node, nativeEvent, modifierKeys) => {
|
||||
const { selection, setSelection } = useViewer.getState();
|
||||
const nodes = useScene.getState().nodes;
|
||||
const nodeLevelId = resolveLevelId(node, nodes);
|
||||
const buildingId = resolveBuildingId(nodeLevelId, nodes);
|
||||
const { selection, setSelection } = useViewer.getState()
|
||||
const nodes = useScene.getState().nodes
|
||||
const nodeLevelId = resolveLevelId(node, nodes)
|
||||
const buildingId = resolveBuildingId(nodeLevelId, nodes)
|
||||
|
||||
const updates: any = {};
|
||||
if (nodeLevelId !== "default" && nodeLevelId !== selection.levelId) {
|
||||
updates.levelId = nodeLevelId;
|
||||
const updates: any = {}
|
||||
if (nodeLevelId !== 'default' && nodeLevelId !== selection.levelId) {
|
||||
updates.levelId = nodeLevelId
|
||||
}
|
||||
if (buildingId && buildingId !== selection.buildingId) {
|
||||
updates.buildingId = buildingId;
|
||||
updates.buildingId = buildingId
|
||||
}
|
||||
|
||||
if (node.type === 'zone') {
|
||||
updates.zoneId = node.id;
|
||||
updates.zoneId = node.id
|
||||
// Don't reset selectedIds in structure phase for zone, but if we changed level, it might reset them via hierarchy guard.
|
||||
// Wait, the hierarchy guard resets zoneId if levelId changes. That's fine since we provide zoneId.
|
||||
setSelection(updates);
|
||||
setSelection(updates)
|
||||
} else {
|
||||
updates.selectedIds = computeNextIds(node, selection.selectedIds, nativeEvent, modifierKeys);
|
||||
setSelection(updates);
|
||||
updates.selectedIds = computeNextIds(node, selection.selectedIds, nativeEvent, modifierKeys)
|
||||
setSelection(updates)
|
||||
}
|
||||
},
|
||||
handleDeselect: () => {
|
||||
const structureLayer = useEditor.getState().structureLayer;
|
||||
if (structureLayer === "zones") {
|
||||
useViewer.getState().setSelection({ zoneId: null });
|
||||
const structureLayer = useEditor.getState().structureLayer
|
||||
if (structureLayer === 'zones') {
|
||||
useViewer.getState().setSelection({ zoneId: null })
|
||||
} else {
|
||||
useViewer.getState().setSelection({ selectedIds: [] });
|
||||
useViewer.getState().setSelection({ selectedIds: [] })
|
||||
}
|
||||
},
|
||||
isValid: (node) => {
|
||||
if (!isNodeInCurrentLevel(node)) return false;
|
||||
const structureLayer = useEditor.getState().structureLayer;
|
||||
if (structureLayer === "zones") {
|
||||
if (node.type === "zone") return true;
|
||||
return false;
|
||||
} else {
|
||||
if (node.type === "wall" || node.type === "slab" || node.type === "ceiling" || node.type === "roof") return true;
|
||||
if (node.type === "item") {
|
||||
return (
|
||||
(node as ItemNode).asset.category === "door" ||
|
||||
(node as ItemNode).asset.category === "window"
|
||||
);
|
||||
}
|
||||
if (node.type === "window" || node.type === "door") return true;
|
||||
|
||||
return false;
|
||||
if (!isNodeInCurrentLevel(node)) return false
|
||||
const structureLayer = useEditor.getState().structureLayer
|
||||
if (structureLayer === 'zones') {
|
||||
if (node.type === 'zone') return true
|
||||
return false
|
||||
}
|
||||
if (
|
||||
node.type === 'wall' ||
|
||||
node.type === 'slab' ||
|
||||
node.type === 'ceiling' ||
|
||||
node.type === 'roof'
|
||||
)
|
||||
return true
|
||||
if (node.type === 'item') {
|
||||
return (
|
||||
(node as ItemNode).asset.category === 'door' ||
|
||||
(node as ItemNode).asset.category === 'window'
|
||||
)
|
||||
}
|
||||
if (node.type === 'window' || node.type === 'door') return true
|
||||
|
||||
return false
|
||||
},
|
||||
},
|
||||
|
||||
furnish: {
|
||||
types: ["item"],
|
||||
types: ['item'],
|
||||
handleSelect: (node, nativeEvent, modifierKeys) => {
|
||||
const { selection, setSelection } = useViewer.getState();
|
||||
const nodes = useScene.getState().nodes;
|
||||
const nodeLevelId = resolveLevelId(node, nodes);
|
||||
const buildingId = resolveBuildingId(nodeLevelId, nodes);
|
||||
const { selection, setSelection } = useViewer.getState()
|
||||
const nodes = useScene.getState().nodes
|
||||
const nodeLevelId = resolveLevelId(node, nodes)
|
||||
const buildingId = resolveBuildingId(nodeLevelId, nodes)
|
||||
|
||||
const updates: any = {};
|
||||
if (nodeLevelId !== "default" && nodeLevelId !== selection.levelId) {
|
||||
updates.levelId = nodeLevelId;
|
||||
const updates: any = {}
|
||||
if (nodeLevelId !== 'default' && nodeLevelId !== selection.levelId) {
|
||||
updates.levelId = nodeLevelId
|
||||
}
|
||||
if (buildingId && buildingId !== selection.buildingId) {
|
||||
updates.buildingId = buildingId;
|
||||
updates.buildingId = buildingId
|
||||
}
|
||||
|
||||
updates.selectedIds = computeNextIds(node, selection.selectedIds, nativeEvent, modifierKeys);
|
||||
setSelection(updates);
|
||||
updates.selectedIds = computeNextIds(node, selection.selectedIds, nativeEvent, modifierKeys)
|
||||
setSelection(updates)
|
||||
},
|
||||
handleDeselect: () => {
|
||||
useViewer.getState().setSelection({ selectedIds: [] });
|
||||
useViewer.getState().setSelection({ selectedIds: [] })
|
||||
},
|
||||
isValid: (node) => {
|
||||
if (!isNodeInCurrentLevel(node)) return false;
|
||||
if (node.type !== "item") return false;
|
||||
const item = node as ItemNode;
|
||||
return item.asset.category !== "door" && item.asset.category !== "window";
|
||||
if (!isNodeInCurrentLevel(node)) return false
|
||||
if (node.type !== 'item') return false
|
||||
const item = node as ItemNode
|
||||
return item.asset.category !== 'door' && item.asset.category !== 'window'
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const SelectionManager = () => {
|
||||
const phase = useEditor((s) => s.phase);
|
||||
const mode = useEditor((s) => s.mode);
|
||||
const phase = useEditor((s) => s.phase)
|
||||
const mode = useEditor((s) => s.mode)
|
||||
const modifierKeysRef = useRef<ModifierKeys>({
|
||||
meta: false,
|
||||
ctrl: false,
|
||||
});
|
||||
const clickHandledRef = useRef(false);
|
||||
})
|
||||
const clickHandledRef = useRef(false)
|
||||
|
||||
const movingNode = useEditor((s) => s.movingNode);
|
||||
const movingNode = useEditor((s) => s.movingNode)
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Meta") modifierKeysRef.current.meta = true;
|
||||
if (event.key === "Control") modifierKeysRef.current.ctrl = true;
|
||||
};
|
||||
if (event.key === 'Meta') modifierKeysRef.current.meta = true
|
||||
if (event.key === 'Control') modifierKeysRef.current.ctrl = true
|
||||
}
|
||||
|
||||
const onKeyUp = (event: KeyboardEvent) => {
|
||||
if (event.key === "Meta") modifierKeysRef.current.meta = false;
|
||||
if (event.key === "Control") modifierKeysRef.current.ctrl = false;
|
||||
};
|
||||
if (event.key === 'Meta') modifierKeysRef.current.meta = false
|
||||
if (event.key === 'Control') modifierKeysRef.current.ctrl = false
|
||||
}
|
||||
|
||||
const clearModifiers = () => {
|
||||
modifierKeysRef.current.meta = false;
|
||||
modifierKeysRef.current.ctrl = false;
|
||||
};
|
||||
modifierKeysRef.current.meta = false
|
||||
modifierKeysRef.current.ctrl = false
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
window.addEventListener("keyup", onKeyUp);
|
||||
window.addEventListener("blur", clearModifiers);
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
window.addEventListener('keyup', onKeyUp)
|
||||
window.addEventListener('blur', clearModifiers)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("keydown", onKeyDown);
|
||||
window.removeEventListener("keyup", onKeyUp);
|
||||
window.removeEventListener("blur", clearModifiers);
|
||||
};
|
||||
}, []);
|
||||
window.removeEventListener('keydown', onKeyDown)
|
||||
window.removeEventListener('keyup', onKeyUp)
|
||||
window.removeEventListener('blur', clearModifiers)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (mode !== "select") return;
|
||||
if (movingNode) return;
|
||||
if (mode !== 'select') return
|
||||
if (movingNode) return
|
||||
|
||||
const onClick = (event: NodeEvent) => {
|
||||
const node = event.node;
|
||||
let currentPhase = useEditor.getState().phase;
|
||||
let targetPhase = currentPhase;
|
||||
const node = event.node
|
||||
let currentPhase = useEditor.getState().phase
|
||||
let targetPhase = currentPhase
|
||||
|
||||
// Auto-switch between structure and furnish phases when clicking elements on the same level
|
||||
if (currentPhase === "structure" || currentPhase === "furnish") {
|
||||
if (currentPhase === 'structure' || currentPhase === 'furnish') {
|
||||
if (isNodeInCurrentLevel(node)) {
|
||||
if (
|
||||
node.type === "wall" ||
|
||||
node.type === "slab" ||
|
||||
node.type === "ceiling" ||
|
||||
node.type === "roof" ||
|
||||
node.type === "window" ||
|
||||
node.type === "door"
|
||||
node.type === 'wall' ||
|
||||
node.type === 'slab' ||
|
||||
node.type === 'ceiling' ||
|
||||
node.type === 'roof' ||
|
||||
node.type === 'window' ||
|
||||
node.type === 'door'
|
||||
) {
|
||||
targetPhase = "structure";
|
||||
} else if (node.type === "item") {
|
||||
const item = node as ItemNode;
|
||||
if (item.asset.category === "door" || item.asset.category === "window") {
|
||||
targetPhase = "structure";
|
||||
targetPhase = 'structure'
|
||||
} else if (node.type === 'item') {
|
||||
const item = node as ItemNode
|
||||
if (item.asset.category === 'door' || item.asset.category === 'window') {
|
||||
targetPhase = 'structure'
|
||||
} else {
|
||||
targetPhase = "furnish";
|
||||
targetPhase = 'furnish'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (targetPhase !== currentPhase) {
|
||||
useEditor.getState().setPhase(targetPhase);
|
||||
if (targetPhase === "structure" && useEditor.getState().structureLayer === "zones") {
|
||||
useEditor.getState().setStructureLayer("elements");
|
||||
useEditor.getState().setPhase(targetPhase)
|
||||
if (targetPhase === 'structure' && useEditor.getState().structureLayer === 'zones') {
|
||||
useEditor.getState().setStructureLayer('elements')
|
||||
}
|
||||
currentPhase = targetPhase;
|
||||
currentPhase = targetPhase
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const activeStrategy = SELECTION_STRATEGIES[currentPhase];
|
||||
const activeStrategy = SELECTION_STRATEGIES[currentPhase]
|
||||
if (activeStrategy?.isValid(node)) {
|
||||
event.stopPropagation();
|
||||
clickHandledRef.current = true;
|
||||
|
||||
console.log("[SelectionManager] Valid click on:", node.type, node.id, "Shift:", event.nativeEvent.shiftKey);
|
||||
activeStrategy.handleSelect(node, event.nativeEvent, modifierKeysRef.current);
|
||||
event.stopPropagation()
|
||||
clickHandledRef.current = true
|
||||
|
||||
console.log(
|
||||
'[SelectionManager] Valid click on:',
|
||||
node.type,
|
||||
node.id,
|
||||
'Shift:',
|
||||
event.nativeEvent.shiftKey,
|
||||
)
|
||||
activeStrategy.handleSelect(node, event.nativeEvent, modifierKeysRef.current)
|
||||
|
||||
// Reset the handled flag after a short delay to allow grid:click to be ignored
|
||||
setTimeout(() => {
|
||||
clickHandledRef.current = false;
|
||||
}, 50);
|
||||
clickHandledRef.current = false
|
||||
}, 50)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const allTypes = ["wall", "item", "building", "zone", "slab", "ceiling", "roof", "window", "door"];
|
||||
const allTypes = [
|
||||
'wall',
|
||||
'item',
|
||||
'building',
|
||||
'zone',
|
||||
'slab',
|
||||
'ceiling',
|
||||
'roof',
|
||||
'window',
|
||||
'door',
|
||||
]
|
||||
allTypes.forEach((type) => {
|
||||
emitter.on(`${type}:click` as any, onClick as any);
|
||||
});
|
||||
emitter.on(`${type}:click` as any, onClick as any)
|
||||
})
|
||||
|
||||
const onGridClick = () => {
|
||||
if (clickHandledRef.current) return;
|
||||
console.log("onGridClick triggered! Deselecting.");
|
||||
const activeStrategy = SELECTION_STRATEGIES[useEditor.getState().phase];
|
||||
if (activeStrategy) activeStrategy.handleDeselect();
|
||||
};
|
||||
emitter.on("grid:click", onGridClick);
|
||||
if (clickHandledRef.current) return
|
||||
console.log('onGridClick triggered! Deselecting.')
|
||||
const activeStrategy = SELECTION_STRATEGIES[useEditor.getState().phase]
|
||||
if (activeStrategy) activeStrategy.handleDeselect()
|
||||
}
|
||||
emitter.on('grid:click', onGridClick)
|
||||
|
||||
return () => {
|
||||
allTypes.forEach((type) => {
|
||||
emitter.off(`${type}:click` as any, onClick as any);
|
||||
});
|
||||
emitter.off("grid:click", onGridClick);
|
||||
};
|
||||
}, [mode, movingNode]);
|
||||
emitter.off(`${type}:click` as any, onClick as any)
|
||||
})
|
||||
emitter.off('grid:click', onGridClick)
|
||||
}
|
||||
}, [mode, movingNode])
|
||||
|
||||
// Global double-click handler for auto-switching phases and cross-phase hover
|
||||
useEffect(() => {
|
||||
if (mode !== "select") return;
|
||||
if (movingNode) return;
|
||||
if (mode !== 'select') return
|
||||
if (movingNode) return
|
||||
|
||||
const onEnter = (event: NodeEvent) => {
|
||||
const node = event.node;
|
||||
const currentPhase = useEditor.getState().phase;
|
||||
const node = event.node
|
||||
const currentPhase = useEditor.getState().phase
|
||||
|
||||
// Ignore site/building if we are already inside a building
|
||||
if (node.type === "building" || node.type === "site") {
|
||||
if (currentPhase === "structure" || currentPhase === "furnish") {
|
||||
return;
|
||||
if (node.type === 'building' || node.type === 'site') {
|
||||
if (currentPhase === 'structure' || currentPhase === 'furnish') {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Ignore zones unless specifically in zones layer
|
||||
if (node.type === "zone") {
|
||||
if (currentPhase !== "structure" || useEditor.getState().structureLayer !== "zones") {
|
||||
return;
|
||||
if (node.type === 'zone') {
|
||||
if (currentPhase !== 'structure' || useEditor.getState().structureLayer !== 'zones') {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Check level constraint for interior nodes
|
||||
if (currentPhase === "structure" || currentPhase === "furnish") {
|
||||
if (!isNodeInCurrentLevel(node)) return;
|
||||
if (currentPhase === 'structure' || currentPhase === 'furnish') {
|
||||
if (!isNodeInCurrentLevel(node)) return
|
||||
}
|
||||
|
||||
event.stopPropagation();
|
||||
useViewer.setState({ hoveredId: node.id });
|
||||
};
|
||||
event.stopPropagation()
|
||||
useViewer.setState({ hoveredId: node.id })
|
||||
}
|
||||
|
||||
const onLeave = (event: NodeEvent) => {
|
||||
if (useViewer.getState().hoveredId === event.node.id) {
|
||||
useViewer.setState({ hoveredId: null });
|
||||
useViewer.setState({ hoveredId: null })
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const onDoubleClick = (event: NodeEvent) => {
|
||||
const node = event.node;
|
||||
const currentPhase = useEditor.getState().phase;
|
||||
|
||||
let targetPhase: "site" | "structure" | "furnish" | null = null;
|
||||
const node = event.node
|
||||
const currentPhase = useEditor.getState().phase
|
||||
|
||||
if (node.type === "building" || node.type === "site") {
|
||||
if (currentPhase === "structure" || currentPhase === "furnish") {
|
||||
return; // Ignore building/site double clicks if we are already inside a building
|
||||
let targetPhase: 'site' | 'structure' | 'furnish' | null = null
|
||||
|
||||
if (node.type === 'building' || node.type === 'site') {
|
||||
if (currentPhase === 'structure' || currentPhase === 'furnish') {
|
||||
return // Ignore building/site double clicks if we are already inside a building
|
||||
}
|
||||
if (node.type === "building") {
|
||||
targetPhase = "structure";
|
||||
if (node.type === 'building') {
|
||||
targetPhase = 'structure'
|
||||
}
|
||||
} else if (
|
||||
node.type === "wall" ||
|
||||
node.type === "slab" ||
|
||||
node.type === "ceiling" ||
|
||||
node.type === "roof" ||
|
||||
node.type === "window" ||
|
||||
node.type === "door"
|
||||
node.type === 'wall' ||
|
||||
node.type === 'slab' ||
|
||||
node.type === 'ceiling' ||
|
||||
node.type === 'roof' ||
|
||||
node.type === 'window' ||
|
||||
node.type === 'door'
|
||||
) {
|
||||
targetPhase = "structure";
|
||||
} else if (node.type === "item") {
|
||||
const item = node as ItemNode;
|
||||
if (item.asset.category === "door" || item.asset.category === "window") {
|
||||
targetPhase = "structure";
|
||||
targetPhase = 'structure'
|
||||
} else if (node.type === 'item') {
|
||||
const item = node as ItemNode
|
||||
if (item.asset.category === 'door' || item.asset.category === 'window') {
|
||||
targetPhase = 'structure'
|
||||
} else {
|
||||
targetPhase = "furnish";
|
||||
targetPhase = 'furnish'
|
||||
}
|
||||
}
|
||||
|
||||
if (node.type === "zone") {
|
||||
return;
|
||||
if (node.type === 'zone') {
|
||||
return
|
||||
}
|
||||
|
||||
if (targetPhase && targetPhase !== useEditor.getState().phase) {
|
||||
event.stopPropagation();
|
||||
|
||||
useEditor.getState().setPhase(targetPhase);
|
||||
|
||||
if (targetPhase === "structure" && useEditor.getState().structureLayer === "zones") {
|
||||
useEditor.getState().setStructureLayer("elements");
|
||||
event.stopPropagation()
|
||||
|
||||
useEditor.getState().setPhase(targetPhase)
|
||||
|
||||
if (targetPhase === 'structure' && useEditor.getState().structureLayer === 'zones') {
|
||||
useEditor.getState().setStructureLayer('elements')
|
||||
}
|
||||
|
||||
const strategy = SELECTION_STRATEGIES[targetPhase];
|
||||
const strategy = SELECTION_STRATEGIES[targetPhase]
|
||||
if (strategy) {
|
||||
strategy.handleSelect(node, event.nativeEvent, modifierKeysRef.current);
|
||||
strategy.handleSelect(node, event.nativeEvent, modifierKeysRef.current)
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const allTypes = ["wall", "item", "building", "slab", "ceiling", "roof", "window", "door", "zone", "site"];
|
||||
const allTypes = [
|
||||
'wall',
|
||||
'item',
|
||||
'building',
|
||||
'slab',
|
||||
'ceiling',
|
||||
'roof',
|
||||
'window',
|
||||
'door',
|
||||
'zone',
|
||||
'site',
|
||||
]
|
||||
allTypes.forEach((type) => {
|
||||
emitter.on(`${type}:enter` as any, onEnter as any);
|
||||
emitter.on(`${type}:leave` as any, onLeave as any);
|
||||
emitter.on(`${type}:double-click` as any, onDoubleClick as any);
|
||||
});
|
||||
emitter.on(`${type}:enter` as any, onEnter as any)
|
||||
emitter.on(`${type}:leave` as any, onLeave as any)
|
||||
emitter.on(`${type}:double-click` as any, onDoubleClick as any)
|
||||
})
|
||||
|
||||
return () => {
|
||||
allTypes.forEach((type) => {
|
||||
emitter.off(`${type}:enter` as any, onEnter as any);
|
||||
emitter.off(`${type}:leave` as any, onLeave as any);
|
||||
emitter.off(`${type}:double-click` as any, onDoubleClick as any);
|
||||
});
|
||||
};
|
||||
}, [mode, movingNode]);
|
||||
emitter.off(`${type}:enter` as any, onEnter as any)
|
||||
emitter.off(`${type}:leave` as any, onLeave as any)
|
||||
emitter.off(`${type}:double-click` as any, onDoubleClick as any)
|
||||
})
|
||||
}
|
||||
}, [mode, movingNode])
|
||||
|
||||
return <EditorOutlinerSync />;
|
||||
};
|
||||
return <EditorOutlinerSync />
|
||||
}
|
||||
|
||||
const EditorOutlinerSync = () => {
|
||||
const phase = useEditor((s) => s.phase);
|
||||
const selection = useViewer((s) => s.selection);
|
||||
const hoveredId = useViewer((s) => s.hoveredId);
|
||||
const outliner = useViewer((s) => s.outliner);
|
||||
const phase = useEditor((s) => s.phase)
|
||||
const selection = useViewer((s) => s.selection)
|
||||
const hoveredId = useViewer((s) => s.hoveredId)
|
||||
const outliner = useViewer((s) => s.outliner)
|
||||
|
||||
useEffect(() => {
|
||||
let idsToHighlight: string[] = [];
|
||||
let idsToHighlight: string[] = []
|
||||
|
||||
// 1. Determine what should be highlighted based on Phase
|
||||
switch (phase) {
|
||||
case "site":
|
||||
case 'site':
|
||||
// Only highlight the building if one is selected
|
||||
if (selection.buildingId) idsToHighlight = [selection.buildingId];
|
||||
break;
|
||||
if (selection.buildingId) idsToHighlight = [selection.buildingId]
|
||||
break
|
||||
|
||||
case "structure":
|
||||
case 'structure':
|
||||
// Highlight selected items (walls/slabs)
|
||||
// We IGNORE buildingId even if it's set in the store
|
||||
idsToHighlight = selection.selectedIds;
|
||||
break;
|
||||
idsToHighlight = selection.selectedIds
|
||||
break
|
||||
|
||||
case "furnish":
|
||||
case 'furnish':
|
||||
// Highlight selected furniture/items
|
||||
idsToHighlight = selection.selectedIds;
|
||||
break;
|
||||
idsToHighlight = selection.selectedIds
|
||||
break
|
||||
|
||||
default:
|
||||
// Pure Viewer mode: Highlight based on the "deepest" selection
|
||||
if (selection.selectedIds.length > 0)
|
||||
idsToHighlight = selection.selectedIds;
|
||||
else if (selection.levelId) idsToHighlight = [selection.levelId];
|
||||
else if (selection.buildingId) idsToHighlight = [selection.buildingId];
|
||||
if (selection.selectedIds.length > 0) idsToHighlight = selection.selectedIds
|
||||
else if (selection.levelId) idsToHighlight = [selection.levelId]
|
||||
else if (selection.buildingId) idsToHighlight = [selection.buildingId]
|
||||
}
|
||||
|
||||
// 2. Sync with the imperative outliner arrays (mutate in place to keep references)
|
||||
outliner.selectedObjects.length = 0;
|
||||
outliner.selectedObjects.length = 0
|
||||
for (const id of idsToHighlight) {
|
||||
const obj = sceneRegistry.nodes.get(id);
|
||||
if (obj) outliner.selectedObjects.push(obj);
|
||||
const obj = sceneRegistry.nodes.get(id)
|
||||
if (obj) outliner.selectedObjects.push(obj)
|
||||
}
|
||||
|
||||
outliner.hoveredObjects.length = 0;
|
||||
outliner.hoveredObjects.length = 0
|
||||
if (hoveredId) {
|
||||
const obj = sceneRegistry.nodes.get(hoveredId);
|
||||
if (obj) outliner.hoveredObjects.push(obj);
|
||||
const obj = sceneRegistry.nodes.get(hoveredId)
|
||||
if (obj) outliner.hoveredObjects.push(obj)
|
||||
}
|
||||
}, [phase, selection, hoveredId, outliner]);
|
||||
}, [phase, selection, hoveredId, outliner])
|
||||
|
||||
return null;
|
||||
};
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { sceneRegistry, useScene } from '@pascal-app/core'
|
||||
import type { SiteNode } from '@pascal-app/core'
|
||||
import { sceneRegistry, useScene } from '@pascal-app/core'
|
||||
import { Html } from '@react-three/drei'
|
||||
import { createPortal, useFrame } from '@react-three/fiber'
|
||||
import { useMemo, useRef, useState } from 'react'
|
||||
@@ -50,10 +50,10 @@ export function SiteEdgeLabels() {
|
||||
<Html
|
||||
center
|
||||
key={`edge-${i}`}
|
||||
occlude
|
||||
position={[edge.midX, 0.5, edge.midZ]}
|
||||
style={{ pointerEvents: 'none', userSelect: 'none' }}
|
||||
zIndexRange={[10, 0]}
|
||||
occlude
|
||||
>
|
||||
<div className="whitespace-nowrap rounded bg-black/75 px-1.5 py-0.5 font-mono text-white text-xs backdrop-blur-sm">
|
||||
{edge.dist.toFixed(2)}m
|
||||
|
||||
@@ -23,7 +23,9 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
|
||||
const pendingAutoRef = useRef(false)
|
||||
const onThumbnailCaptureRef = useRef(onThumbnailCapture)
|
||||
|
||||
useEffect(() => { onThumbnailCaptureRef.current = onThumbnailCapture }, [onThumbnailCapture])
|
||||
useEffect(() => {
|
||||
onThumbnailCaptureRef.current = onThumbnailCapture
|
||||
}, [onThumbnailCapture])
|
||||
|
||||
const generate = useCallback(async () => {
|
||||
if (isGenerating.current) return
|
||||
@@ -32,7 +34,12 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
|
||||
isGenerating.current = true
|
||||
|
||||
try {
|
||||
const thumbnailCamera = new THREE.PerspectiveCamera(60, THUMBNAIL_WIDTH / THUMBNAIL_HEIGHT, 0.1, 1000)
|
||||
const thumbnailCamera = new THREE.PerspectiveCamera(
|
||||
60,
|
||||
THUMBNAIL_WIDTH / THUMBNAIL_HEIGHT,
|
||||
0.1,
|
||||
1000,
|
||||
)
|
||||
|
||||
const nodes = useScene.getState().nodes
|
||||
const siteNode = Object.values(nodes).find((n) => n.type === 'site')
|
||||
@@ -74,7 +81,10 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
|
||||
|
||||
const srcAspect = width / height
|
||||
const dstAspect = THUMBNAIL_WIDTH / THUMBNAIL_HEIGHT
|
||||
let sx = 0, sy = 0, sWidth = width, sHeight = height
|
||||
let sx = 0,
|
||||
sy = 0,
|
||||
sWidth = width,
|
||||
sHeight = height
|
||||
if (srcAspect > dstAspect) {
|
||||
sWidth = Math.round(height * dstAspect)
|
||||
sx = Math.round((width - sWidth) / 2)
|
||||
|
||||
@@ -17,9 +17,17 @@ const MAX_IMAGE_SIZE = 5 * 1024 * 1024
|
||||
|
||||
type ImagePreview = { file: File; url: string }
|
||||
|
||||
export function FeedbackDialog({ projectId: projectIdProp, onSubmit }: {
|
||||
export function FeedbackDialog({
|
||||
projectId: projectIdProp,
|
||||
onSubmit,
|
||||
}: {
|
||||
projectId?: string
|
||||
onSubmit?: (data: { message: string; projectId?: string; sceneGraph: unknown; images: File[] }) => Promise<{ success: boolean; error?: string }>
|
||||
onSubmit?: (data: {
|
||||
message: string
|
||||
projectId?: string
|
||||
sceneGraph: unknown
|
||||
images: File[]
|
||||
}) => Promise<{ success: boolean; error?: string }>
|
||||
}) {
|
||||
const projectId = projectIdProp
|
||||
|
||||
@@ -120,7 +128,7 @@ export function FeedbackDialog({ projectId: projectIdProp, onSubmit }: {
|
||||
message,
|
||||
projectId,
|
||||
sceneGraph,
|
||||
images: images.map(img => img.file),
|
||||
images: images.map((img) => img.file),
|
||||
})
|
||||
if (result.success) {
|
||||
setSent(true)
|
||||
@@ -136,14 +144,14 @@ export function FeedbackDialog({ projectId: projectIdProp, onSubmit }: {
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
className="flex items-center gap-2 rounded-lg border border-border bg-background/95 px-3 py-2 font-medium text-sm shadow-lg backdrop-blur-md transition-colors hover:bg-accent/90"
|
||||
onClick={handleOpen}
|
||||
className="flex items-center gap-2 rounded-lg border border-border bg-background/95 px-3 py-2 text-sm font-medium shadow-lg backdrop-blur-md hover:bg-accent/90 transition-colors"
|
||||
>
|
||||
<MessageSquare className="h-4 w-4" />
|
||||
Feedback
|
||||
</button>
|
||||
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<Dialog onOpenChange={handleClose} open={open}>
|
||||
<DialogContent
|
||||
className="sm:max-w-[460px]"
|
||||
onDragEnter={onDragEnter}
|
||||
@@ -153,10 +161,10 @@ export function FeedbackDialog({ projectId: projectIdProp, onSubmit }: {
|
||||
>
|
||||
{/* Drag overlay — only visible when dragging files over the dialog */}
|
||||
{isDragging && (
|
||||
<div className="absolute inset-0 z-50 flex items-center justify-center rounded-lg border-2 border-dashed border-primary/50 bg-primary/5 backdrop-blur-sm transition-all">
|
||||
<div className="absolute inset-0 z-50 flex items-center justify-center rounded-lg border-2 border-primary/50 border-dashed bg-primary/5 backdrop-blur-sm transition-all">
|
||||
<div className="flex flex-col items-center gap-2 text-primary/70">
|
||||
<ImageIcon className="h-8 w-8" />
|
||||
<p className="text-sm font-medium">Drop images here</p>
|
||||
<p className="font-medium text-sm">Drop images here</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -167,24 +175,24 @@ export function FeedbackDialog({ projectId: projectIdProp, onSubmit }: {
|
||||
</DialogHeader>
|
||||
|
||||
{sent ? (
|
||||
<p className="py-4 text-center text-sm text-muted-foreground">
|
||||
<p className="py-4 text-center text-muted-foreground text-sm">
|
||||
Thanks for your feedback!
|
||||
</p>
|
||||
) : (
|
||||
<form className="space-y-4" onSubmit={handleSubmit}>
|
||||
<div>
|
||||
<label htmlFor="feedback-message" className="text-sm font-medium">
|
||||
<label className="font-medium text-sm" htmlFor="feedback-message">
|
||||
Your feedback
|
||||
</label>
|
||||
<textarea
|
||||
autoFocus
|
||||
className="mt-1 w-full resize-none rounded-md border border-border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
disabled={isSubmitting}
|
||||
id="feedback-message"
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
placeholder="Share your thoughts, suggestions, feature requests, or report issues..."
|
||||
rows={5}
|
||||
className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm resize-none focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
disabled={isSubmitting}
|
||||
autoFocus
|
||||
value={message}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -193,14 +201,14 @@ export function FeedbackDialog({ projectId: projectIdProp, onSubmit }: {
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{images.map((img, i) => (
|
||||
<div
|
||||
key={img.url}
|
||||
className="group relative h-14 w-14 overflow-hidden rounded-md border border-border"
|
||||
key={img.url}
|
||||
>
|
||||
<img src={img.url} alt="" className="h-full w-full object-cover" />
|
||||
<img alt="" className="h-full w-full object-cover" src={img.url} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeImage(i)}
|
||||
className="absolute inset-0 flex items-center justify-center bg-black/50 opacity-0 transition-opacity group-hover:opacity-100"
|
||||
onClick={() => removeImage(i)}
|
||||
type="button"
|
||||
>
|
||||
<X className="h-4 w-4 text-white" />
|
||||
</button>
|
||||
@@ -209,41 +217,41 @@ export function FeedbackDialog({ projectId: projectIdProp, onSubmit }: {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
{error && <p className="text-destructive text-sm">{error}</p>}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
{/* Subtle attach button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="flex items-center gap-1.5 text-muted-foreground text-xs transition-colors hover:text-foreground disabled:opacity-40"
|
||||
disabled={isSubmitting || images.length >= MAX_IMAGES}
|
||||
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors disabled:opacity-40"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
type="button"
|
||||
>
|
||||
<ImageIcon className="h-3.5 w-3.5" />
|
||||
{images.length > 0 ? `${images.length}/${MAX_IMAGES}` : 'Attach'}
|
||||
</button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
className="hidden"
|
||||
multiple
|
||||
onChange={(e) => {
|
||||
if (e.target.files) addFiles(e.target.files)
|
||||
e.target.value = ''
|
||||
}}
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
/>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
disabled={isSubmitting}
|
||||
onClick={handleClose}
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleClose}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting || !message.trim() || !onSubmit}>
|
||||
<Button disabled={isSubmitting || !message.trim() || !onSubmit} type="submit">
|
||||
{isSubmitting ? 'Sending...' : 'Send Feedback'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import { AnimatePresence, motion } from 'motion/react'
|
||||
import { Howl } from 'howler'
|
||||
import { Disc3, Settings2, SkipBack, SkipForward, Volume2, VolumeX } from 'lucide-react'
|
||||
import { AnimatePresence, motion } from 'motion/react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Slider } from '../components/ui/slider'
|
||||
import { cn } from '../lib/utils'
|
||||
@@ -163,32 +163,30 @@ export function PascalRadio() {
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
ref={containerRef}
|
||||
className={cn(
|
||||
'flex flex-col overflow-hidden rounded-lg border border-border bg-background/95 shadow-lg backdrop-blur-md',
|
||||
!isOpen && 'cursor-pointer transition-colors hover:bg-accent/90',
|
||||
)}
|
||||
layout
|
||||
onClick={() => {
|
||||
if (!isOpen) setIsOpen(true)
|
||||
}}
|
||||
ref={containerRef}
|
||||
transition={{ type: 'spring', bounce: 0.2, duration: 0.6 }}
|
||||
className={cn(
|
||||
'flex flex-col rounded-lg border border-border bg-background/95 shadow-lg backdrop-blur-md overflow-hidden',
|
||||
!isOpen && 'cursor-pointer hover:bg-accent/90 transition-colors',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2 px-3 py-2 text-sm font-medium">
|
||||
<div className="flex items-center justify-between gap-2 px-3 py-2 font-medium text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<Disc3 className={cn('h-4 w-4 shrink-0', isRadioPlaying && 'animate-spin')} />
|
||||
<span className="hidden sm:inline whitespace-nowrap">Radio Pascal</span>
|
||||
<span className="hidden whitespace-nowrap sm:inline">Radio Pascal</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
aria-label={isRadioPlaying ? 'Pause' : 'Play'}
|
||||
className="cursor-pointer rounded-sm bg-accent/30 p-1 transition-all hover:bg-accent hover:text-accent-foreground hover:shadow-sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handlePlayPause()
|
||||
}}
|
||||
className="rounded-sm p-1 transition-all cursor-pointer bg-accent/30 hover:bg-accent hover:text-accent-foreground hover:shadow-sm"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={isRadioPlaying ? 'Pause' : 'Play'}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
@@ -196,6 +194,8 @@ export function PascalRadio() {
|
||||
handlePlayPause()
|
||||
}
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
{isRadioPlaying ? (
|
||||
<Volume2 className="h-3.5 w-3.5" />
|
||||
@@ -204,15 +204,15 @@ export function PascalRadio() {
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
aria-label="Radio Settings"
|
||||
className={cn(
|
||||
'cursor-pointer rounded-sm p-1 transition-all hover:bg-accent hover:text-accent-foreground',
|
||||
isOpen && 'bg-accent text-accent-foreground',
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setIsOpen(!isOpen)
|
||||
}}
|
||||
className={cn(
|
||||
'rounded-sm p-1 transition-all cursor-pointer hover:bg-accent hover:text-accent-foreground',
|
||||
isOpen && 'bg-accent text-accent-foreground',
|
||||
)}
|
||||
aria-label="Radio Settings"
|
||||
>
|
||||
<Settings2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
@@ -222,34 +222,34 @@ export function PascalRadio() {
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
transition={{ type: 'spring', bounce: 0.2, duration: 0.6 }}
|
||||
>
|
||||
<div className="px-3 pb-3 space-y-3 w-[16rem]">
|
||||
<div className="h-px w-full bg-border/50 mb-3" />
|
||||
<div className="w-[16rem] space-y-3 px-3 pb-3">
|
||||
<div className="mb-3 h-px w-full bg-border/50" />
|
||||
{/* Current song info with prev/next */}
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground mb-2">Now Playing</p>
|
||||
<p className="mb-2 text-muted-foreground text-xs">Now Playing</p>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<button
|
||||
onClick={handlePrevious}
|
||||
className="rounded-full p-1.5 transition-colors hover:bg-accent shrink-0"
|
||||
aria-label="Previous"
|
||||
className="shrink-0 rounded-full p-1.5 transition-colors hover:bg-accent"
|
||||
onClick={handlePrevious}
|
||||
>
|
||||
<SkipBack className="h-4 w-4" />
|
||||
</button>
|
||||
<p
|
||||
className="text-sm font-medium text-center flex-1 truncate"
|
||||
className="flex-1 truncate text-center font-medium text-sm"
|
||||
title={currentTrack.title}
|
||||
>
|
||||
{currentTrack.title}
|
||||
</p>
|
||||
<button
|
||||
onClick={handleNext}
|
||||
className="rounded-full p-1.5 transition-colors hover:bg-accent shrink-0"
|
||||
aria-label="Next"
|
||||
className="shrink-0 rounded-full p-1.5 transition-colors hover:bg-accent"
|
||||
onClick={handleNext}
|
||||
>
|
||||
<SkipForward className="h-4 w-4" />
|
||||
</button>
|
||||
@@ -258,16 +258,16 @@ export function PascalRadio() {
|
||||
|
||||
{/* Volume control */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Volume2 className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
|
||||
<Volume2 className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<Slider
|
||||
value={[radioVolume]}
|
||||
onValueChange={handleVolumeChange}
|
||||
max={100}
|
||||
step={1}
|
||||
className="flex-1"
|
||||
aria-label="Radio Volume"
|
||||
className="flex-1"
|
||||
max={100}
|
||||
onValueChange={handleVolumeChange}
|
||||
step={1}
|
||||
value={[radioVolume]}
|
||||
/>
|
||||
<span className="w-8 text-right text-xs text-muted-foreground shrink-0">
|
||||
<span className="w-8 shrink-0 text-right text-muted-foreground text-xs">
|
||||
{radioVolume}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -6,11 +6,11 @@ import useEditor from '../store/use-editor'
|
||||
export function PreviewButton() {
|
||||
return (
|
||||
<button
|
||||
className="flex cursor-pointer items-center gap-2 rounded-lg border border-border bg-background/95 px-3 py-2 font-medium text-sm shadow-lg backdrop-blur-md transition-colors hover:bg-accent/90"
|
||||
onClick={() => useEditor.getState().setPreviewMode(true)}
|
||||
className="flex items-center gap-2 rounded-lg border border-border bg-background/95 shadow-lg backdrop-blur-md px-3 py-2 text-sm font-medium cursor-pointer hover:bg-accent/90 transition-colors"
|
||||
>
|
||||
<Eye className="h-4 w-4 shrink-0" />
|
||||
<span className="hidden sm:inline whitespace-nowrap">Preview</span>
|
||||
<span className="hidden whitespace-nowrap sm:inline">Preview</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -12,10 +12,10 @@ export const CeilingSystem = () => {
|
||||
|
||||
useEffect(() => {
|
||||
const nodes = useScene.getState().nodes
|
||||
|
||||
|
||||
const levelsToShowCeilings = new Set<string>()
|
||||
|
||||
const isCeilingToolActive =
|
||||
const isCeilingToolActive =
|
||||
tool === 'ceiling' ||
|
||||
selectedItem?.attachTo === 'ceiling' ||
|
||||
(movingNode?.type === 'item' && movingNode?.asset?.attachTo === 'ceiling')
|
||||
@@ -45,7 +45,7 @@ export const CeilingSystem = () => {
|
||||
levelsToShowCeilings.add(levelId)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const ceilings = sceneRegistry.byType.ceiling
|
||||
ceilings.forEach((ceiling) => {
|
||||
const mesh = sceneRegistry.nodes.get(ceiling)
|
||||
@@ -54,7 +54,7 @@ export const CeilingSystem = () => {
|
||||
if (ceilingGrid) {
|
||||
let belongsToVisibleLevel = false
|
||||
let currentId: string | null = ceiling
|
||||
|
||||
|
||||
while (currentId && nodes[currentId as AnyNodeId]) {
|
||||
const node = nodes[currentId as AnyNodeId]
|
||||
if (node && levelsToShowCeilings.has(node.id)) {
|
||||
@@ -64,8 +64,8 @@ export const CeilingSystem = () => {
|
||||
currentId = node?.parentId as string | null
|
||||
}
|
||||
|
||||
const shouldShowGrid = belongsToVisibleLevel ||
|
||||
(levelsToShowCeilings.size === 0 && isCeilingToolActive)
|
||||
const shouldShowGrid =
|
||||
belongsToVisibleLevel || (levelsToShowCeilings.size === 0 && isCeilingToolActive)
|
||||
|
||||
ceilingGrid.visible = shouldShowGrid
|
||||
ceilingGrid.scale.setScalar(shouldShowGrid ? 1 : 0.0) // Scale down to zero to prevent event interference when grid is hidden
|
||||
|
||||
@@ -21,7 +21,9 @@ function ZoneLabelEditor({ zoneId }: { zoneId: ZoneNode['id'] }) {
|
||||
|
||||
// Keep a ref so the click handler never has a stale zone name
|
||||
const zoneNameRef = useRef(zone?.name ?? '')
|
||||
useEffect(() => { zoneNameRef.current = zone?.name ?? '' }, [zone?.name])
|
||||
useEffect(() => {
|
||||
zoneNameRef.current = zone?.name ?? ''
|
||||
}, [zone?.name])
|
||||
|
||||
// Setup: find the label element, enable pointer events, and hide the
|
||||
// zone-renderer's own text node (children[0]) — we replace it via portal.
|
||||
@@ -87,22 +89,26 @@ function ZoneLabelEditor({ zoneId }: { zoneId: ZoneNode['id'] }) {
|
||||
return createPortal(
|
||||
editing ? (
|
||||
<div
|
||||
style={sharedStyle}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
style={sharedStyle}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={value}
|
||||
onBlur={save}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => {
|
||||
e.stopPropagation()
|
||||
if (e.key === 'Enter') { e.preventDefault(); save() }
|
||||
if (e.key === 'Escape') { e.preventDefault(); cancel() }
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
save()
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
cancel()
|
||||
}
|
||||
}}
|
||||
onBlur={save}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
ref={inputRef}
|
||||
style={{
|
||||
width: `${Math.max((value || zone?.name || '').length + 1, 4)}ch`,
|
||||
border: 'none',
|
||||
@@ -118,10 +124,14 @@ function ZoneLabelEditor({ zoneId }: { zoneId: ZoneNode['id'] }) {
|
||||
fontFamily: 'inherit',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
type="text"
|
||||
value={value}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); save() }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
save()
|
||||
}}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
background: 'none',
|
||||
@@ -132,14 +142,13 @@ function ZoneLabelEditor({ zoneId }: { zoneId: ZoneNode['id'] }) {
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<Check size={12} />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
style={{ ...sharedStyle, background: 'none', border: 'none', cursor: 'text', padding: 0 }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setSelection({ zoneId })
|
||||
@@ -147,6 +156,8 @@ function ZoneLabelEditor({ zoneId }: { zoneId: ZoneNode['id'] }) {
|
||||
setEditing(true)
|
||||
}}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
style={{ ...sharedStyle, background: 'none', border: 'none', cursor: 'text', padding: 0 }}
|
||||
type="button"
|
||||
>
|
||||
<span>{zone?.name}</span>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', opacity: 0.55 }}>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { resolveLevelId, type CeilingNode, useScene } from '@pascal-app/core'
|
||||
import { type CeilingNode, resolveLevelId, useScene } from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useCallback } from 'react'
|
||||
import { PolygonEditor } from '../shared/polygon-editor'
|
||||
@@ -27,15 +27,15 @@ export const CeilingBoundaryEditor: React.FC<CeilingBoundaryEditorProps> = ({ ce
|
||||
[ceilingId, updateNode, setSelection],
|
||||
)
|
||||
|
||||
if (!ceiling || !ceiling.polygon || ceiling.polygon.length < 3) return null
|
||||
if (!(ceiling && ceiling.polygon) || ceiling.polygon.length < 3) return null
|
||||
|
||||
return (
|
||||
<PolygonEditor
|
||||
polygon={ceiling.polygon}
|
||||
color="#d4d4d4"
|
||||
onPolygonChange={handlePolygonChange}
|
||||
minVertices={3}
|
||||
levelId={resolveLevelId(ceiling, useScene.getState().nodes)}
|
||||
minVertices={3}
|
||||
onPolygonChange={handlePolygonChange}
|
||||
polygon={ceiling.polygon}
|
||||
surfaceHeight={ceiling.height ?? 2.5}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { resolveLevelId, type CeilingNode, useScene } from '@pascal-app/core'
|
||||
import { type CeilingNode, resolveLevelId, useScene } from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useCallback } from 'react'
|
||||
import { PolygonEditor } from '../shared/polygon-editor'
|
||||
@@ -32,15 +32,15 @@ export const CeilingHoleEditor: React.FC<CeilingHoleEditorProps> = ({ ceilingId,
|
||||
[ceilingId, holeIndex, holes, updateNode, setSelection],
|
||||
)
|
||||
|
||||
if (!ceiling || !hole || hole.length < 3) return null
|
||||
if (!(ceiling && hole) || hole.length < 3) return null
|
||||
|
||||
return (
|
||||
<PolygonEditor
|
||||
polygon={hole}
|
||||
color="#ef4444" // red for holes
|
||||
onPolygonChange={handlePolygonChange}
|
||||
color="#ef4444"
|
||||
levelId={resolveLevelId(ceiling, useScene.getState().nodes)} // red for holes
|
||||
minVertices={3}
|
||||
levelId={resolveLevelId(ceiling, useScene.getState().nodes)}
|
||||
onPolygonChange={handlePolygonChange}
|
||||
polygon={hole}
|
||||
surfaceHeight={ceiling.height ?? 2.5}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { CeilingNode, emitter, type GridEvent, type LevelNode, useScene } from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { BufferGeometry, DoubleSide, type Line, type Group, Shape, Vector3 } from 'three'
|
||||
import { BufferGeometry, DoubleSide, type Group, type Line, Shape, Vector3 } from 'three'
|
||||
import { mix, positionLocal } from 'three/tsl'
|
||||
import { EDITOR_LAYER } from '../../../lib/constants'
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
@@ -37,19 +37,22 @@ const calculateSnapPoint = (
|
||||
// Snap to 45° diagonal
|
||||
const diagonalLength = Math.min(absDx, absDy)
|
||||
return [x1 + Math.sign(dx) * diagonalLength, y1 + Math.sign(dy) * diagonalLength]
|
||||
} else if (minDist === horizontalDist) {
|
||||
}
|
||||
if (minDist === horizontalDist) {
|
||||
// Snap to horizontal
|
||||
return [x, y1]
|
||||
} else {
|
||||
// Snap to vertical
|
||||
return [x1, y]
|
||||
}
|
||||
// Snap to vertical
|
||||
return [x1, y]
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a ceiling with the given polygon points and returns its ID
|
||||
*/
|
||||
const commitCeilingDrawing = (levelId: LevelNode['id'], points: Array<[number, number]>): string => {
|
||||
const commitCeilingDrawing = (
|
||||
levelId: LevelNode['id'],
|
||||
points: Array<[number, number]>,
|
||||
): string => {
|
||||
const { createNode, nodes } = useScene.getState()
|
||||
|
||||
// Count existing ceilings for naming
|
||||
@@ -86,7 +89,11 @@ export const CeilingTool: React.FC = () => {
|
||||
|
||||
// Static geometry: local y goes 0 (grid) → H (ceiling), mesh is positioned at gridY
|
||||
const verticalGeo = useMemo(
|
||||
() => new BufferGeometry().setFromPoints([new Vector3(0, 0, 0), new Vector3(0, CEILING_HEIGHT - GRID_OFFSET, 0)]),
|
||||
() =>
|
||||
new BufferGeometry().setFromPoints([
|
||||
new Vector3(0, 0, 0),
|
||||
new Vector3(0, CEILING_HEIGHT - GRID_OFFSET, 0),
|
||||
]),
|
||||
[],
|
||||
)
|
||||
|
||||
@@ -101,7 +108,7 @@ export const CeilingTool: React.FC = () => {
|
||||
if (!currentLevelId) return
|
||||
|
||||
const onGridMove = (event: GridEvent) => {
|
||||
if (!cursorRef.current || !gridCursorRef.current) return
|
||||
if (!(cursorRef.current && gridCursorRef.current)) return
|
||||
|
||||
const gridX = Math.round(event.position[0] * 2) / 2
|
||||
const gridZ = Math.round(event.position[2] * 2) / 2
|
||||
@@ -205,7 +212,7 @@ export const CeilingTool: React.FC = () => {
|
||||
|
||||
// Update line geometries when points change
|
||||
useEffect(() => {
|
||||
if (!mainLineRef.current || !closingLineRef.current) return
|
||||
if (!(mainLineRef.current && closingLineRef.current)) return
|
||||
|
||||
if (points.length === 0) {
|
||||
mainLineRef.current.visible = false
|
||||
@@ -254,7 +261,9 @@ export const CeilingTool: React.FC = () => {
|
||||
new Vector3(firstPoint[0], gridY, firstPoint[1]),
|
||||
]
|
||||
groundClosingLineRef.current.geometry.dispose()
|
||||
groundClosingLineRef.current.geometry = new BufferGeometry().setFromPoints(groundClosingPoints)
|
||||
groundClosingLineRef.current.geometry = new BufferGeometry().setFromPoints(
|
||||
groundClosingPoints,
|
||||
)
|
||||
groundClosingLineRef.current.visible = true
|
||||
} else {
|
||||
closingLineRef.current.visible = false
|
||||
@@ -296,15 +305,33 @@ export const CeilingTool: React.FC = () => {
|
||||
<CursorSphere ref={cursorRef} />
|
||||
|
||||
{/* Grid-level cursor indicator */}
|
||||
<mesh ref={gridCursorRef} rotation={[-Math.PI / 2, 0, 0]} renderOrder={2} layers={EDITOR_LAYER}>
|
||||
<mesh
|
||||
layers={EDITOR_LAYER}
|
||||
ref={gridCursorRef}
|
||||
renderOrder={2}
|
||||
rotation={[-Math.PI / 2, 0, 0]}
|
||||
>
|
||||
<ringGeometry args={[0.15, 0.2, 32]} />
|
||||
<meshBasicMaterial color="#818cf8" side={DoubleSide} depthTest={false} depthWrite={true} opacity={0.5} transparent />
|
||||
<meshBasicMaterial
|
||||
color="#818cf8"
|
||||
depthTest={false}
|
||||
depthWrite={true}
|
||||
opacity={0.5}
|
||||
side={DoubleSide}
|
||||
transparent
|
||||
/>
|
||||
</mesh>
|
||||
|
||||
{/* Vertical connector: local y=0 at grid, y=H at ceiling; position.y set to gridY on move */}
|
||||
{/* @ts-ignore */}
|
||||
<line ref={verticalLineRef} geometry={verticalGeo} renderOrder={1} layers={EDITOR_LAYER}>
|
||||
<lineBasicNodeMaterial color="#818cf8" opacityNode={gradientOpacityNode} depthTest={false} depthWrite={false} transparent />
|
||||
<line geometry={verticalGeo} layers={EDITOR_LAYER} ref={verticalLineRef} renderOrder={1}>
|
||||
<lineBasicNodeMaterial
|
||||
color="#818cf8"
|
||||
depthTest={false}
|
||||
depthWrite={false}
|
||||
opacityNode={gradientOpacityNode}
|
||||
transparent
|
||||
/>
|
||||
</line>
|
||||
|
||||
{/* Preview fill (Top) */}
|
||||
@@ -347,20 +374,32 @@ export const CeilingTool: React.FC = () => {
|
||||
|
||||
{/* Main line */}
|
||||
{/* @ts-ignore */}
|
||||
<line ref={mainLineRef} frustumCulled={false} renderOrder={1} visible={false} layers={EDITOR_LAYER}>
|
||||
<line
|
||||
frustumCulled={false}
|
||||
layers={EDITOR_LAYER}
|
||||
ref={mainLineRef}
|
||||
renderOrder={1}
|
||||
visible={false}
|
||||
>
|
||||
<bufferGeometry />
|
||||
<lineBasicNodeMaterial color="#818cf8" linewidth={3} depthTest={false} depthWrite={false} />
|
||||
<lineBasicNodeMaterial color="#818cf8" depthTest={false} depthWrite={false} linewidth={3} />
|
||||
</line>
|
||||
|
||||
{/* Closing line */}
|
||||
{/* @ts-ignore */}
|
||||
<line ref={closingLineRef} frustumCulled={false} renderOrder={1} visible={false} layers={EDITOR_LAYER}>
|
||||
<line
|
||||
frustumCulled={false}
|
||||
layers={EDITOR_LAYER}
|
||||
ref={closingLineRef}
|
||||
renderOrder={1}
|
||||
visible={false}
|
||||
>
|
||||
<bufferGeometry />
|
||||
<lineBasicNodeMaterial
|
||||
color="#818cf8"
|
||||
linewidth={2}
|
||||
depthTest={false}
|
||||
depthWrite={false}
|
||||
linewidth={2}
|
||||
opacity={0.5}
|
||||
transparent
|
||||
/>
|
||||
@@ -368,20 +407,39 @@ export const CeilingTool: React.FC = () => {
|
||||
|
||||
{/* Ground main line */}
|
||||
{/* @ts-ignore */}
|
||||
<line ref={groundMainLineRef} frustumCulled={false} renderOrder={1} visible={false} layers={EDITOR_LAYER}>
|
||||
<line
|
||||
frustumCulled={false}
|
||||
layers={EDITOR_LAYER}
|
||||
ref={groundMainLineRef}
|
||||
renderOrder={1}
|
||||
visible={false}
|
||||
>
|
||||
<bufferGeometry />
|
||||
<lineBasicNodeMaterial color="#818cf8" linewidth={3} depthTest={false} depthWrite={false} opacity={0.3} transparent />
|
||||
<lineBasicNodeMaterial
|
||||
color="#818cf8"
|
||||
depthTest={false}
|
||||
depthWrite={false}
|
||||
linewidth={3}
|
||||
opacity={0.3}
|
||||
transparent
|
||||
/>
|
||||
</line>
|
||||
|
||||
{/* Ground closing line */}
|
||||
{/* @ts-ignore */}
|
||||
<line ref={groundClosingLineRef} frustumCulled={false} renderOrder={1} visible={false} layers={EDITOR_LAYER}>
|
||||
<line
|
||||
frustumCulled={false}
|
||||
layers={EDITOR_LAYER}
|
||||
ref={groundClosingLineRef}
|
||||
renderOrder={1}
|
||||
visible={false}
|
||||
>
|
||||
<bufferGeometry />
|
||||
<lineBasicNodeMaterial
|
||||
color="#818cf8"
|
||||
linewidth={2}
|
||||
depthTest={false}
|
||||
depthWrite={false}
|
||||
linewidth={2}
|
||||
opacity={0.15}
|
||||
transparent
|
||||
/>
|
||||
@@ -390,9 +448,9 @@ export const CeilingTool: React.FC = () => {
|
||||
{/* Point markers */}
|
||||
{points.map(([x, z], index) => (
|
||||
<CursorSphere
|
||||
color="#818cf8"
|
||||
key={index}
|
||||
position={[x, levelY + CEILING_HEIGHT + 0.01, z]}
|
||||
color="#818cf8"
|
||||
showTooltip={false}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import { type AnyNodeId, type DoorNode, getScaledDimensions, type ItemNode, useScene, type WallNode, type WindowNode } from '@pascal-app/core'
|
||||
import {
|
||||
type AnyNodeId,
|
||||
type DoorNode,
|
||||
getScaledDimensions,
|
||||
type ItemNode,
|
||||
useScene,
|
||||
type WallNode,
|
||||
type WindowNode,
|
||||
} from '@pascal-app/core'
|
||||
|
||||
/**
|
||||
* Converts wall-local (X along wall, Y = height above wall base) to world XYZ.
|
||||
@@ -36,7 +44,7 @@ export function clampToWall(
|
||||
const wallLength = Math.sqrt(dx * dx + dz * dz)
|
||||
|
||||
const clampedX = Math.max(width / 2, Math.min(wallLength - width / 2, localX))
|
||||
const clampedY = height / 2 // Doors always sit at floor level
|
||||
const clampedY = height / 2 // Doors always sit at floor level
|
||||
return { clampedX, clampedY }
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,8 @@ import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { BoxGeometry, EdgesGeometry, type Group, type LineSegments } from 'three'
|
||||
import { LineBasicNodeMaterial } from 'three/webgpu'
|
||||
import { EDITOR_LAYER } from '../../../lib/constants'
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
import {
|
||||
calculateCursorRotation,
|
||||
calculateItemRotation,
|
||||
@@ -19,11 +21,9 @@ import {
|
||||
snapToHalf,
|
||||
} from '../item/placement-math'
|
||||
import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './door-math'
|
||||
import { EDITOR_LAYER } from '../../../lib/constants'
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
|
||||
const edgeMaterial = new LineBasicNodeMaterial({
|
||||
color: 0xef4444,
|
||||
color: 0xef_44_44,
|
||||
linewidth: 3,
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
@@ -79,7 +79,7 @@ export const DoorTool: React.FC = () => {
|
||||
group.visible = true
|
||||
group.position.set(...worldPosition)
|
||||
group.rotation.y = cursorRotationY
|
||||
edgeMaterial.color.setHex(valid ? 0x22c55e : 0xef4444)
|
||||
edgeMaterial.color.setHex(valid ? 0x22_c5_5e : 0xef_44_44)
|
||||
}
|
||||
|
||||
const onWallEnter = (event: WallEvent) => {
|
||||
@@ -115,7 +115,13 @@ export const DoorTool: React.FC = () => {
|
||||
const valid = !hasWallChildOverlap(event.node.id, clampedX, clampedY, width, height, node.id)
|
||||
|
||||
updateCursor(
|
||||
wallLocalToWorld(event.node, clampedX, clampedY, getLevelYOffset(), getSlabElevation(event)),
|
||||
wallLocalToWorld(
|
||||
event.node,
|
||||
clampedX,
|
||||
clampedY,
|
||||
getLevelYOffset(),
|
||||
getSlabElevation(event),
|
||||
),
|
||||
cursorRotation,
|
||||
valid,
|
||||
)
|
||||
@@ -147,12 +153,22 @@ export const DoorTool: React.FC = () => {
|
||||
}
|
||||
|
||||
const valid = !hasWallChildOverlap(
|
||||
event.node.id, clampedX, clampedY, width, height,
|
||||
event.node.id,
|
||||
clampedX,
|
||||
clampedY,
|
||||
width,
|
||||
height,
|
||||
draftRef.current?.id,
|
||||
)
|
||||
|
||||
updateCursor(
|
||||
wallLocalToWorld(event.node, clampedX, clampedY, getLevelYOffset(), getSlabElevation(event)),
|
||||
wallLocalToWorld(
|
||||
event.node,
|
||||
clampedX,
|
||||
clampedY,
|
||||
getLevelYOffset(),
|
||||
getSlabElevation(event),
|
||||
),
|
||||
cursorRotation,
|
||||
valid,
|
||||
)
|
||||
@@ -169,12 +185,17 @@ export const DoorTool: React.FC = () => {
|
||||
|
||||
const localX = snapToHalf(event.localPosition[0])
|
||||
const { clampedX, clampedY } = clampToWall(
|
||||
event.node, localX,
|
||||
draftRef.current.width, draftRef.current.height,
|
||||
event.node,
|
||||
localX,
|
||||
draftRef.current.width,
|
||||
draftRef.current.height,
|
||||
)
|
||||
const valid = !hasWallChildOverlap(
|
||||
event.node.id, clampedX, clampedY,
|
||||
draftRef.current.width, draftRef.current.height,
|
||||
event.node.id,
|
||||
clampedX,
|
||||
clampedY,
|
||||
draftRef.current.width,
|
||||
draftRef.current.height,
|
||||
draftRef.current.id,
|
||||
)
|
||||
if (!valid) return
|
||||
@@ -261,7 +282,12 @@ export const DoorTool: React.FC = () => {
|
||||
|
||||
return (
|
||||
<group ref={cursorGroupRef} visible={false}>
|
||||
<lineSegments ref={edgesRef} geometry={edgesGeo} material={edgeMaterial} layers={EDITOR_LAYER} />
|
||||
<lineSegments
|
||||
geometry={edgesGeo}
|
||||
layers={EDITOR_LAYER}
|
||||
material={edgeMaterial}
|
||||
ref={edgesRef}
|
||||
/>
|
||||
</group>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './door-math'
|
||||
|
||||
const edgeMaterial = new LineBasicNodeMaterial({
|
||||
color: 0xef4444,
|
||||
color: 0xef_44_44,
|
||||
linewidth: 3,
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
@@ -40,9 +40,10 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod
|
||||
useEffect(() => {
|
||||
useScene.temporal.getState().pause()
|
||||
|
||||
const meta = (typeof movingDoorNode.metadata === 'object' && movingDoorNode.metadata !== null)
|
||||
? movingDoorNode.metadata as Record<string, unknown>
|
||||
: {}
|
||||
const meta =
|
||||
typeof movingDoorNode.metadata === 'object' && movingDoorNode.metadata !== null
|
||||
? (movingDoorNode.metadata as Record<string, unknown>)
|
||||
: {}
|
||||
const isNew = !!meta.isNew
|
||||
|
||||
const original = {
|
||||
@@ -92,7 +93,7 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod
|
||||
group.visible = true
|
||||
group.position.set(...worldPosition)
|
||||
group.rotation.y = cursorRotationY
|
||||
edgeMaterial.color.setHex(valid ? 0x22c55e : 0xef4444)
|
||||
edgeMaterial.color.setHex(valid ? 0x22_c5_5e : 0xef_44_44)
|
||||
}
|
||||
|
||||
const onWallEnter = (event: WallEvent) => {
|
||||
@@ -105,8 +106,10 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod
|
||||
|
||||
const localX = snapToHalf(event.localPosition[0])
|
||||
const { clampedX, clampedY } = clampToWall(
|
||||
event.node, localX,
|
||||
movingDoorNode.width, movingDoorNode.height,
|
||||
event.node,
|
||||
localX,
|
||||
movingDoorNode.width,
|
||||
movingDoorNode.height,
|
||||
)
|
||||
|
||||
const prevWallId = currentWallId
|
||||
@@ -124,13 +127,22 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod
|
||||
markWallDirty(event.node.id)
|
||||
|
||||
const valid = !hasWallChildOverlap(
|
||||
event.node.id, clampedX, clampedY,
|
||||
movingDoorNode.width, movingDoorNode.height,
|
||||
event.node.id,
|
||||
clampedX,
|
||||
clampedY,
|
||||
movingDoorNode.width,
|
||||
movingDoorNode.height,
|
||||
movingDoorNode.id,
|
||||
)
|
||||
|
||||
updateCursor(
|
||||
wallLocalToWorld(event.node, clampedX, clampedY, getLevelYOffset(), getSlabElevation(event)),
|
||||
wallLocalToWorld(
|
||||
event.node,
|
||||
clampedX,
|
||||
clampedY,
|
||||
getLevelYOffset(),
|
||||
getSlabElevation(event),
|
||||
),
|
||||
cursorRotation,
|
||||
valid,
|
||||
)
|
||||
@@ -147,8 +159,10 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod
|
||||
|
||||
const localX = snapToHalf(event.localPosition[0])
|
||||
const { clampedX, clampedY } = clampToWall(
|
||||
event.node, localX,
|
||||
movingDoorNode.width, movingDoorNode.height,
|
||||
event.node,
|
||||
localX,
|
||||
movingDoorNode.width,
|
||||
movingDoorNode.height,
|
||||
)
|
||||
|
||||
useScene.getState().updateNode(movingDoorNode.id, {
|
||||
@@ -166,13 +180,22 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod
|
||||
markWallDirty(event.node.id)
|
||||
|
||||
const valid = !hasWallChildOverlap(
|
||||
event.node.id, clampedX, clampedY,
|
||||
movingDoorNode.width, movingDoorNode.height,
|
||||
event.node.id,
|
||||
clampedX,
|
||||
clampedY,
|
||||
movingDoorNode.width,
|
||||
movingDoorNode.height,
|
||||
movingDoorNode.id,
|
||||
)
|
||||
|
||||
updateCursor(
|
||||
wallLocalToWorld(event.node, clampedX, clampedY, getLevelYOffset(), getSlabElevation(event)),
|
||||
wallLocalToWorld(
|
||||
event.node,
|
||||
clampedX,
|
||||
clampedY,
|
||||
getLevelYOffset(),
|
||||
getSlabElevation(event),
|
||||
),
|
||||
cursorRotation,
|
||||
valid,
|
||||
)
|
||||
@@ -188,13 +211,18 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod
|
||||
|
||||
const localX = snapToHalf(event.localPosition[0])
|
||||
const { clampedX, clampedY } = clampToWall(
|
||||
event.node, localX,
|
||||
movingDoorNode.width, movingDoorNode.height,
|
||||
event.node,
|
||||
localX,
|
||||
movingDoorNode.width,
|
||||
movingDoorNode.height,
|
||||
)
|
||||
|
||||
const valid = !hasWallChildOverlap(
|
||||
event.node.id, clampedX, clampedY,
|
||||
movingDoorNode.width, movingDoorNode.height,
|
||||
event.node.id,
|
||||
clampedX,
|
||||
clampedY,
|
||||
movingDoorNode.width,
|
||||
movingDoorNode.height,
|
||||
movingDoorNode.id,
|
||||
)
|
||||
if (!valid) return
|
||||
@@ -297,7 +325,9 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod
|
||||
emitter.on('tool:cancel', onCancel)
|
||||
|
||||
return () => {
|
||||
const current = useScene.getState().nodes[movingDoorNode.id as AnyNodeId] as DoorNode | undefined
|
||||
const current = useScene.getState().nodes[movingDoorNode.id as AnyNodeId] as
|
||||
| DoorNode
|
||||
| undefined
|
||||
const currentMeta = current?.metadata as Record<string, unknown> | undefined
|
||||
if (currentMeta?.isTransient) {
|
||||
if (isNew) {
|
||||
@@ -337,7 +367,7 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod
|
||||
|
||||
return (
|
||||
<group ref={cursorGroupRef} visible={false}>
|
||||
<lineSegments geometry={edgesGeo} material={edgeMaterial} layers={EDITOR_LAYER} />
|
||||
<lineSegments geometry={edgesGeo} layers={EDITOR_LAYER} material={edgeMaterial} />
|
||||
</group>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -25,16 +25,19 @@ function getInitialState(node: {
|
||||
function MoveItemContent({ movingNode }: { movingNode: ItemNode }) {
|
||||
const draftNode = useDraftNode()
|
||||
|
||||
const meta = (typeof movingNode.metadata === 'object' && movingNode.metadata !== null)
|
||||
? movingNode.metadata as Record<string, unknown>
|
||||
: {}
|
||||
const meta =
|
||||
typeof movingNode.metadata === 'object' && movingNode.metadata !== null
|
||||
? (movingNode.metadata as Record<string, unknown>)
|
||||
: {}
|
||||
const isNew = !!meta.isNew
|
||||
|
||||
const cursor = usePlacementCoordinator({
|
||||
asset: movingNode.asset,
|
||||
draftNode,
|
||||
// Duplicates start fresh in floor mode; wall/ceiling draft is created lazily by ensureDraft
|
||||
initialState: isNew ? { surface: 'floor', wallId: null, ceilingId: null, surfaceItemId: null } : getInitialState(movingNode),
|
||||
initialState: isNew
|
||||
? { surface: 'floor', wallId: null, ceilingId: null, surfaceItemId: null }
|
||||
: getInitialState(movingNode),
|
||||
// Preserve the original item's scale so Y-position calculations use the correct height
|
||||
defaultScale: isNew ? movingNode.scale : undefined,
|
||||
initDraft: (gridPosition) => {
|
||||
|
||||
@@ -35,9 +35,8 @@ export function calculateCursorRotation(
|
||||
// In local wall space, front face has normal.z < 0, back face has normal.z > 0
|
||||
if (normal[2] < 0) {
|
||||
return -wallAngle
|
||||
} else {
|
||||
return Math.PI - wallAngle
|
||||
}
|
||||
return Math.PI - wallAngle
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -11,14 +11,6 @@ import type {
|
||||
} from '@pascal-app/core'
|
||||
import { getScaledDimensions, sceneRegistry, useScene } from '@pascal-app/core'
|
||||
import { Vector3 } from 'three'
|
||||
import type {
|
||||
CommitResult,
|
||||
LevelResolver,
|
||||
PlacementContext,
|
||||
PlacementResult,
|
||||
SpatialValidators,
|
||||
TransitionResult,
|
||||
} from './placement-types'
|
||||
import {
|
||||
calculateCursorRotation,
|
||||
calculateItemRotation,
|
||||
@@ -28,6 +20,14 @@ import {
|
||||
snapToHalf,
|
||||
stripTransient,
|
||||
} from './placement-math'
|
||||
import type {
|
||||
CommitResult,
|
||||
LevelResolver,
|
||||
PlacementContext,
|
||||
PlacementResult,
|
||||
SpatialValidators,
|
||||
TransitionResult,
|
||||
} from './placement-types'
|
||||
|
||||
const DEFAULT_DIMENSIONS: [number, number, number] = [1, 1, 1]
|
||||
|
||||
@@ -43,7 +43,9 @@ export const floorStrategy = {
|
||||
move(ctx: PlacementContext, event: GridEvent): PlacementResult | null {
|
||||
if (ctx.state.surface !== 'floor') return null
|
||||
|
||||
const dims = ctx.draftItem ? getScaledDimensions(ctx.draftItem) : (ctx.asset.dimensions ?? DEFAULT_DIMENSIONS)
|
||||
const dims = ctx.draftItem
|
||||
? getScaledDimensions(ctx.draftItem)
|
||||
: (ctx.asset.dimensions ?? DEFAULT_DIMENSIONS)
|
||||
const [dimX, , dimZ] = dims
|
||||
const x = snapToGrid(event.position[0], dimX)
|
||||
const z = snapToGrid(event.position[2], dimZ)
|
||||
@@ -62,9 +64,13 @@ export const floorStrategy = {
|
||||
* Handle grid:click — commit placement on floor.
|
||||
* Returns null if on wall/ceiling or validation fails.
|
||||
*/
|
||||
click(ctx: PlacementContext, _event: GridEvent, validators: SpatialValidators): CommitResult | null {
|
||||
click(
|
||||
ctx: PlacementContext,
|
||||
_event: GridEvent,
|
||||
validators: SpatialValidators,
|
||||
): CommitResult | null {
|
||||
if (ctx.state.surface !== 'floor') return null
|
||||
if (!ctx.levelId || !ctx.draftItem) return null
|
||||
if (!(ctx.levelId && ctx.draftItem)) return null
|
||||
|
||||
const pos: [number, number, number] = [ctx.gridPosition.x, 0, ctx.gridPosition.z]
|
||||
const valid = validators.canPlaceOnFloor(
|
||||
@@ -128,7 +134,9 @@ export const wallStrategy = {
|
||||
event.node.id,
|
||||
x,
|
||||
y,
|
||||
ctx.draftItem ? getScaledDimensions(ctx.draftItem) : (ctx.asset.dimensions ?? DEFAULT_DIMENSIONS),
|
||||
ctx.draftItem
|
||||
? getScaledDimensions(ctx.draftItem)
|
||||
: (ctx.asset.dimensions ?? DEFAULT_DIMENSIONS),
|
||||
attachTo,
|
||||
side,
|
||||
[],
|
||||
@@ -160,9 +168,13 @@ export const wallStrategy = {
|
||||
* Returns null if not on a wall or face is invalid.
|
||||
* Auto-adjusts Y position to fit within wall bounds.
|
||||
*/
|
||||
move(ctx: PlacementContext, event: WallEvent, validators: SpatialValidators): PlacementResult | null {
|
||||
move(
|
||||
ctx: PlacementContext,
|
||||
event: WallEvent,
|
||||
validators: SpatialValidators,
|
||||
): PlacementResult | null {
|
||||
if (ctx.state.surface !== 'wall') return null
|
||||
if (!ctx.draftItem || !ctx.levelId) return null
|
||||
if (!(ctx.draftItem && ctx.levelId)) return null
|
||||
if (!isValidWallSideFace(event.normal)) return null
|
||||
|
||||
const side = getSideFromNormal(event.normal)
|
||||
@@ -209,10 +221,14 @@ export const wallStrategy = {
|
||||
* Handle wall:click — commit placement on wall.
|
||||
* Returns null if not on wall, face invalid, or validation fails.
|
||||
*/
|
||||
click(ctx: PlacementContext, event: WallEvent, validators: SpatialValidators): CommitResult | null {
|
||||
click(
|
||||
ctx: PlacementContext,
|
||||
event: WallEvent,
|
||||
validators: SpatialValidators,
|
||||
): CommitResult | null {
|
||||
if (ctx.state.surface !== 'wall') return null
|
||||
if (!isValidWallSideFace(event.normal)) return null
|
||||
if (!ctx.levelId || !ctx.draftItem) return null
|
||||
if (!(ctx.levelId && ctx.draftItem)) return null
|
||||
|
||||
const valid = validators.canPlaceOnWall(
|
||||
ctx.levelId,
|
||||
@@ -281,7 +297,9 @@ export const ceilingStrategy = {
|
||||
const ceilingLevelId = resolveLevelId(event.node, nodes)
|
||||
if (ctx.levelId !== ceilingLevelId) return null
|
||||
|
||||
const dims = ctx.draftItem ? getScaledDimensions(ctx.draftItem) : (ctx.asset.dimensions ?? DEFAULT_DIMENSIONS)
|
||||
const dims = ctx.draftItem
|
||||
? getScaledDimensions(ctx.draftItem)
|
||||
: (ctx.asset.dimensions ?? DEFAULT_DIMENSIONS)
|
||||
const [dimX, , dimZ] = dims
|
||||
const itemHeight = dims[1]
|
||||
|
||||
@@ -328,7 +346,11 @@ export const ceilingStrategy = {
|
||||
/**
|
||||
* Handle ceiling:click — commit placement on ceiling.
|
||||
*/
|
||||
click(ctx: PlacementContext, event: CeilingEvent, validators: SpatialValidators): CommitResult | null {
|
||||
click(
|
||||
ctx: PlacementContext,
|
||||
event: CeilingEvent,
|
||||
validators: SpatialValidators,
|
||||
): CommitResult | null {
|
||||
if (ctx.state.surface !== 'ceiling') return null
|
||||
if (!ctx.draftItem) return null
|
||||
|
||||
@@ -399,7 +421,9 @@ export const itemSurfaceStrategy = {
|
||||
if (!surfaceItem.asset.surface) return null
|
||||
|
||||
// Size check: our footprint must fit on surface item's footprint
|
||||
const ourDims = ctx.draftItem ? getScaledDimensions(ctx.draftItem) : (ctx.asset.dimensions ?? DEFAULT_DIMENSIONS)
|
||||
const ourDims = ctx.draftItem
|
||||
? getScaledDimensions(ctx.draftItem)
|
||||
: (ctx.asset.dimensions ?? DEFAULT_DIMENSIONS)
|
||||
const surfDims = getScaledDimensions(surfaceItem)
|
||||
if (ourDims[0] > surfDims[0] || ourDims[2] > surfDims[2]) return null
|
||||
|
||||
@@ -430,7 +454,7 @@ export const itemSurfaceStrategy = {
|
||||
*/
|
||||
move(ctx: PlacementContext, event: ItemEvent): PlacementResult | null {
|
||||
if (ctx.state.surface !== 'item-surface') return null
|
||||
if (!ctx.state.surfaceItemId || !ctx.draftItem) return null
|
||||
if (!(ctx.state.surfaceItemId && ctx.draftItem)) return null
|
||||
|
||||
const nodes = useScene.getState().nodes
|
||||
const surfaceItem = nodes[ctx.state.surfaceItemId as AnyNodeId] as ItemNode | undefined
|
||||
@@ -464,7 +488,7 @@ export const itemSurfaceStrategy = {
|
||||
*/
|
||||
click(ctx: PlacementContext, _event: ItemEvent): CommitResult | null {
|
||||
if (ctx.state.surface !== 'item-surface') return null
|
||||
if (!ctx.draftItem || !ctx.state.surfaceItemId) return null
|
||||
if (!(ctx.draftItem && ctx.state.surfaceItemId)) return null
|
||||
|
||||
return {
|
||||
nodeUpdate: {
|
||||
@@ -487,7 +511,7 @@ export const itemSurfaceStrategy = {
|
||||
* Switches on the active surface type and calls the appropriate spatial validator.
|
||||
*/
|
||||
export function checkCanPlace(ctx: PlacementContext, validators: SpatialValidators): boolean {
|
||||
if (!ctx.levelId || !ctx.draftItem) return false
|
||||
if (!(ctx.levelId && ctx.draftItem)) return false
|
||||
|
||||
// Item surface: valid if we entered (size check was in enter)
|
||||
if (ctx.state.surface === 'item-surface') {
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import type { AnyNode, AssetInput, CeilingNode, ItemNode, LevelNode, WallNode } from '@pascal-app/core'
|
||||
import type {
|
||||
AnyNode,
|
||||
AssetInput,
|
||||
CeilingNode,
|
||||
ItemNode,
|
||||
LevelNode,
|
||||
WallNode,
|
||||
} from '@pascal-app/core'
|
||||
import type { Vector3 } from 'three'
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { type AnyNodeId, type AssetInput, ItemNode, sceneRegistry, useScene } from '@pascal-app/core'
|
||||
import {
|
||||
type AnyNodeId,
|
||||
type AssetInput,
|
||||
ItemNode,
|
||||
sceneRegistry,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useCallback, useMemo, useRef } from 'react'
|
||||
import type { Vector3 } from 'three'
|
||||
@@ -18,7 +24,12 @@ export interface DraftNodeHandle {
|
||||
/** Whether the current draft was adopted (move mode) vs created (create mode) */
|
||||
readonly isAdopted: boolean
|
||||
/** Create a new draft item at the given position. Returns the created node or null. */
|
||||
create: (gridPosition: Vector3, asset: AssetInput, rotation?: [number, number, number], scale?: [number, number, number]) => ItemNode | null
|
||||
create: (
|
||||
gridPosition: Vector3,
|
||||
asset: AssetInput,
|
||||
rotation?: [number, number, number],
|
||||
scale?: [number, number, number],
|
||||
) => ItemNode | null
|
||||
/** Take ownership of an existing scene node as the draft (for move mode). */
|
||||
adopt: (node: ItemNode) => void
|
||||
/** Commit the current draft. Create mode: delete+recreate. Move mode: update in place. */
|
||||
@@ -40,32 +51,41 @@ export function useDraftNode(): DraftNodeHandle {
|
||||
const adoptedRef = useRef(false)
|
||||
const originalStateRef = useRef<OriginalState | null>(null)
|
||||
|
||||
const create = useCallback((gridPosition: Vector3, asset: AssetInput, rotation?: [number, number, number], scale?: [number, number, number]): ItemNode | null => {
|
||||
const currentLevelId = useViewer.getState().selection.levelId
|
||||
if (!currentLevelId) return null
|
||||
const create = useCallback(
|
||||
(
|
||||
gridPosition: Vector3,
|
||||
asset: AssetInput,
|
||||
rotation?: [number, number, number],
|
||||
scale?: [number, number, number],
|
||||
): ItemNode | null => {
|
||||
const currentLevelId = useViewer.getState().selection.levelId
|
||||
if (!currentLevelId) return null
|
||||
|
||||
const node = ItemNode.parse({
|
||||
position: [gridPosition.x, gridPosition.y, gridPosition.z],
|
||||
rotation: rotation ?? [0, 0, 0],
|
||||
scale: scale ?? [1, 1, 1],
|
||||
name: asset.name,
|
||||
asset,
|
||||
parentId: currentLevelId,
|
||||
metadata: { isTransient: true },
|
||||
})
|
||||
const node = ItemNode.parse({
|
||||
position: [gridPosition.x, gridPosition.y, gridPosition.z],
|
||||
rotation: rotation ?? [0, 0, 0],
|
||||
scale: scale ?? [1, 1, 1],
|
||||
name: asset.name,
|
||||
asset,
|
||||
parentId: currentLevelId,
|
||||
metadata: { isTransient: true },
|
||||
})
|
||||
|
||||
useScene.getState().createNode(node, currentLevelId)
|
||||
draftRef.current = node
|
||||
adoptedRef.current = false
|
||||
originalStateRef.current = null
|
||||
return node
|
||||
}, [])
|
||||
useScene.getState().createNode(node, currentLevelId)
|
||||
draftRef.current = node
|
||||
adoptedRef.current = false
|
||||
originalStateRef.current = null
|
||||
return node
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
const adopt = useCallback((node: ItemNode): void => {
|
||||
// Save original state so destroy() can restore it
|
||||
const meta = (typeof node.metadata === 'object' && node.metadata !== null && !Array.isArray(node.metadata))
|
||||
? node.metadata as Record<string, unknown>
|
||||
: {}
|
||||
const meta =
|
||||
typeof node.metadata === 'object' && node.metadata !== null && !Array.isArray(node.metadata)
|
||||
? (node.metadata as Record<string, unknown>)
|
||||
: {}
|
||||
|
||||
originalStateRef.current = {
|
||||
position: [...node.position] as [number, number, number],
|
||||
@@ -91,7 +111,8 @@ export function useDraftNode(): DraftNodeHandle {
|
||||
if (adoptedRef.current) {
|
||||
// Move mode: update in place (single undoable action)
|
||||
const { parentId: newParentId, ...updateProps } = finalUpdate
|
||||
const parentId = newParentId ?? originalStateRef.current?.parentId ?? useViewer.getState().selection.levelId
|
||||
const parentId =
|
||||
newParentId ?? originalStateRef.current?.parentId ?? useViewer.getState().selection.levelId
|
||||
const original = originalStateRef.current!
|
||||
|
||||
// Restore original state while paused — so the undo baseline is clean
|
||||
|
||||
@@ -3,8 +3,8 @@ import {
|
||||
type AnyNodeId,
|
||||
type CeilingEvent,
|
||||
emitter,
|
||||
getScaledDimensions,
|
||||
type GridEvent,
|
||||
getScaledDimensions,
|
||||
type ItemEvent,
|
||||
resolveLevelId,
|
||||
sceneRegistry,
|
||||
@@ -32,7 +32,13 @@ import { distance, smoothstep, uv, vec2 } from 'three/tsl'
|
||||
import { LineBasicNodeMaterial, MeshBasicNodeMaterial } from 'three/webgpu'
|
||||
import { EDITOR_LAYER } from '../../../lib/constants'
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
import { ceilingStrategy, checkCanPlace, floorStrategy, itemSurfaceStrategy, wallStrategy } from './placement-strategies'
|
||||
import {
|
||||
ceilingStrategy,
|
||||
checkCanPlace,
|
||||
floorStrategy,
|
||||
itemSurfaceStrategy,
|
||||
wallStrategy,
|
||||
} from './placement-strategies'
|
||||
import type { PlacementState, TransitionResult } from './placement-types'
|
||||
import type { DraftNodeHandle } from './use-draft-node'
|
||||
|
||||
@@ -41,14 +47,14 @@ const DEFAULT_DIMENSIONS: [number, number, number] = [1, 1, 1]
|
||||
// Shared materials for placement cursor - we just change colors, not swap materials
|
||||
// Note: EdgesGeometry doesn't work with dashed lines, so using solid lines
|
||||
const edgeMaterial = new LineBasicNodeMaterial({
|
||||
color: 0xef4444, // red-500 (invalid)
|
||||
color: 0xef_44_44, // red-500 (invalid)
|
||||
linewidth: 3,
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
})
|
||||
|
||||
const basePlaneMaterial = new MeshBasicNodeMaterial({
|
||||
color: 0xef4444, // red-500 (invalid)
|
||||
color: 0xef_44_44, // red-500 (invalid)
|
||||
transparent: true,
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
@@ -111,13 +117,18 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
||||
state: { ...placementState.current },
|
||||
})
|
||||
|
||||
const getActiveValidators = () => shiftFreeRef.current
|
||||
? { canPlaceOnFloor: () => ({ valid: true }), canPlaceOnWall: () => ({ valid: true }), canPlaceOnCeiling: () => ({ valid: true }) }
|
||||
: validators
|
||||
const getActiveValidators = () =>
|
||||
shiftFreeRef.current
|
||||
? {
|
||||
canPlaceOnFloor: () => ({ valid: true }),
|
||||
canPlaceOnWall: () => ({ valid: true }),
|
||||
canPlaceOnCeiling: () => ({ valid: true }),
|
||||
}
|
||||
: validators
|
||||
|
||||
const revalidate = (): boolean => {
|
||||
const placeable = shiftFreeRef.current || checkCanPlace(getContext(), validators)
|
||||
const color = placeable ? 0x22c55e : 0xef4444 // green-500 : red-500
|
||||
const color = placeable ? 0x22_c5_5e : 0xef_44_44 // green-500 : red-500
|
||||
edgeMaterial.color.setHex(color)
|
||||
basePlaneMaterial.color.setHex(color)
|
||||
return placeable
|
||||
@@ -143,7 +154,12 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
||||
cursorGroupRef.current.position.set(...result.cursorPosition)
|
||||
cursorGroupRef.current.rotation.y = result.cursorRotationY
|
||||
|
||||
draftNode.create(gridPosition.current, asset, [0, result.cursorRotationY, 0], configRef.current.defaultScale)
|
||||
draftNode.create(
|
||||
gridPosition.current,
|
||||
asset,
|
||||
[0, result.cursorRotationY, 0],
|
||||
configRef.current.defaultScale,
|
||||
)
|
||||
|
||||
const draft = draftNode.current
|
||||
if (draft) {
|
||||
@@ -224,7 +240,13 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
||||
|
||||
const onWallEnter = (event: WallEvent) => {
|
||||
const nodes = useScene.getState().nodes
|
||||
const result = wallStrategy.enter(getContext(), event, resolveLevelId, nodes, getActiveValidators())
|
||||
const result = wallStrategy.enter(
|
||||
getContext(),
|
||||
event,
|
||||
resolveLevelId,
|
||||
nodes,
|
||||
getActiveValidators(),
|
||||
)
|
||||
if (!result) return
|
||||
|
||||
event.stopPropagation()
|
||||
@@ -246,7 +268,13 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
||||
|
||||
if (ctx.state.surface !== 'wall') {
|
||||
const nodes = useScene.getState().nodes
|
||||
const enterResult = wallStrategy.enter(ctx, event, resolveLevelId, nodes, getActiveValidators())
|
||||
const enterResult = wallStrategy.enter(
|
||||
ctx,
|
||||
event,
|
||||
resolveLevelId,
|
||||
nodes,
|
||||
getActiveValidators(),
|
||||
)
|
||||
if (!enterResult) return
|
||||
|
||||
event.stopPropagation()
|
||||
@@ -262,7 +290,13 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
||||
|
||||
if (!draftNode.current) {
|
||||
const nodes = useScene.getState().nodes
|
||||
const setup = wallStrategy.enter(getContext(), event, resolveLevelId, nodes, getActiveValidators())
|
||||
const setup = wallStrategy.enter(
|
||||
getContext(),
|
||||
event,
|
||||
resolveLevelId,
|
||||
nodes,
|
||||
getActiveValidators(),
|
||||
)
|
||||
if (!setup) return
|
||||
|
||||
event.stopPropagation()
|
||||
@@ -334,7 +368,13 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
||||
|
||||
if (configRef.current.onCommitted()) {
|
||||
const nodes = useScene.getState().nodes
|
||||
const enterResult = wallStrategy.enter(getContext(), event, resolveLevelId, nodes, validators)
|
||||
const enterResult = wallStrategy.enter(
|
||||
getContext(),
|
||||
event,
|
||||
resolveLevelId,
|
||||
nodes,
|
||||
validators,
|
||||
)
|
||||
if (enterResult) {
|
||||
applyTransition(enterResult)
|
||||
} else {
|
||||
@@ -703,7 +743,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
||||
const viewerLevelId = useViewer((s) => s.selection.levelId)
|
||||
useEffect(() => {
|
||||
const draft = draftNode.current
|
||||
if (!draft || !viewerLevelId || asset.attachTo) return
|
||||
if (!(draft && viewerLevelId) || asset.attachTo) return
|
||||
if (draft.parentId === viewerLevelId) return
|
||||
draft.parentId = viewerLevelId
|
||||
useScene.getState().updateNode(draft.id as AnyNodeId, { parentId: viewerLevelId })
|
||||
@@ -749,7 +789,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
||||
})
|
||||
|
||||
const initialDraft = draftNode.current
|
||||
const dims = initialDraft ? getScaledDimensions(initialDraft) : (config.asset.dimensions ?? DEFAULT_DIMENSIONS)
|
||||
const dims = initialDraft
|
||||
? getScaledDimensions(initialDraft)
|
||||
: (config.asset.dimensions ?? DEFAULT_DIMENSIONS)
|
||||
const initialBoxGeometry = new BoxGeometry(dims[0], dims[1], dims[2])
|
||||
initialBoxGeometry.translate(0, dims[1] / 2, 0)
|
||||
|
||||
@@ -760,10 +802,15 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
||||
|
||||
return (
|
||||
<group ref={cursorGroupRef}>
|
||||
<lineSegments ref={edgesRef} material={edgeMaterial} layers={EDITOR_LAYER}>
|
||||
<lineSegments layers={EDITOR_LAYER} material={edgeMaterial} ref={edgesRef}>
|
||||
<edgesGeometry args={[initialBoxGeometry]} />
|
||||
</lineSegments>
|
||||
<mesh ref={basePlaneRef} geometry={basePlaneGeometry} material={basePlaneMaterial} layers={EDITOR_LAYER} />
|
||||
<mesh
|
||||
geometry={basePlaneGeometry}
|
||||
layers={EDITOR_LAYER}
|
||||
material={basePlaneMaterial}
|
||||
ref={basePlaneRef}
|
||||
/>
|
||||
</group>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { BufferGeometry, DoubleSide, type Line, type Group, Vector3 } from 'three'
|
||||
import { BufferGeometry, DoubleSide, type Group, type Line, Vector3 } from 'three'
|
||||
import { EDITOR_LAYER } from '../../../lib/constants'
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
import useEditor from '../../../store/use-editor'
|
||||
@@ -90,7 +90,7 @@ export const RoofTool: React.FC = () => {
|
||||
corner2: [number, number, number],
|
||||
) => {
|
||||
const gridY = corner1[1] + GRID_OFFSET
|
||||
|
||||
|
||||
const groundPoints = [
|
||||
new Vector3(corner1[0], gridY, corner1[2]),
|
||||
new Vector3(corner2[0], gridY, corner1[2]),
|
||||
@@ -98,7 +98,7 @@ export const RoofTool: React.FC = () => {
|
||||
new Vector3(corner1[0], gridY, corner2[2]),
|
||||
new Vector3(corner1[0], gridY, corner1[2]), // Close the loop
|
||||
]
|
||||
|
||||
|
||||
outlineRef.current.geometry.dispose()
|
||||
outlineRef.current.geometry = new BufferGeometry().setFromPoints(groundPoints)
|
||||
outlineRef.current.visible = true
|
||||
@@ -116,7 +116,7 @@ export const RoofTool: React.FC = () => {
|
||||
|
||||
// Update cursors
|
||||
const gridY = y + GRID_OFFSET
|
||||
|
||||
|
||||
cursorRef.current.position.set(gridX, gridY, gridZ)
|
||||
|
||||
// Play snap sound when grid position changes (only when placing)
|
||||
@@ -149,14 +149,7 @@ export const RoofTool: React.FC = () => {
|
||||
const gridZ = Math.round(event.position[2] * 2) / 2
|
||||
const y = event.position[1]
|
||||
|
||||
if (!corner1Ref.current) {
|
||||
// First click - set corner 1
|
||||
corner1Ref.current = [gridX, y, gridZ]
|
||||
setPreview((prev) => ({
|
||||
...prev,
|
||||
corner1: corner1Ref.current,
|
||||
}))
|
||||
} else {
|
||||
if (corner1Ref.current) {
|
||||
// Second click - create the roof
|
||||
const roofId = commitRoofPlacement(currentLevelId, corner1Ref.current, [gridX, y, gridZ])
|
||||
|
||||
@@ -166,6 +159,13 @@ export const RoofTool: React.FC = () => {
|
||||
// Reset state
|
||||
corner1Ref.current = null
|
||||
outlineRef.current.visible = false
|
||||
} else {
|
||||
// First click - set corner 1
|
||||
corner1Ref.current = [gridX, y, gridZ]
|
||||
setPreview((prev) => ({
|
||||
...prev,
|
||||
corner1: corner1Ref.current,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -211,16 +211,29 @@ export const RoofTool: React.FC = () => {
|
||||
|
||||
{/* Outline showing rectangle being drawn (Ground) */}
|
||||
{/* @ts-ignore */}
|
||||
<line ref={outlineRef} frustumCulled={false} renderOrder={1} visible={false} layers={EDITOR_LAYER}>
|
||||
<line
|
||||
frustumCulled={false}
|
||||
layers={EDITOR_LAYER}
|
||||
ref={outlineRef}
|
||||
renderOrder={1}
|
||||
visible={false}
|
||||
>
|
||||
<bufferGeometry />
|
||||
<lineBasicNodeMaterial color="#818cf8" linewidth={2} depthTest={false} depthWrite={false} opacity={0.3} transparent />
|
||||
<lineBasicNodeMaterial
|
||||
color="#818cf8"
|
||||
depthTest={false}
|
||||
depthWrite={false}
|
||||
linewidth={2}
|
||||
opacity={0.3}
|
||||
transparent
|
||||
/>
|
||||
</line>
|
||||
|
||||
{/* First corner marker */}
|
||||
{corner1 && (
|
||||
<CursorSphere
|
||||
position={[corner1[0], levelY + GRID_OFFSET, corner1[2]]}
|
||||
color="#818cf8"
|
||||
<CursorSphere
|
||||
color="#818cf8"
|
||||
position={[corner1[0], levelY + GRID_OFFSET, corner1[2]]}
|
||||
showTooltip={false}
|
||||
/>
|
||||
)}
|
||||
@@ -235,11 +248,11 @@ export const RoofTool: React.FC = () => {
|
||||
<planeGeometry args={[previewDimensions.length, previewDimensions.width]} />
|
||||
<meshBasicMaterial
|
||||
color="#818cf8"
|
||||
opacity={0.1}
|
||||
transparent
|
||||
side={DoubleSide}
|
||||
depthTest={false}
|
||||
depthWrite={false}
|
||||
opacity={0.1}
|
||||
side={DoubleSide}
|
||||
transparent
|
||||
/>
|
||||
</mesh>
|
||||
)}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Html } from '@react-three/drei'
|
||||
import type { ThreeElements } from '@react-three/fiber'
|
||||
import { forwardRef } from 'react'
|
||||
import type { Group } from 'three'
|
||||
import { Html } from '@react-three/drei'
|
||||
import { furnishTools } from '../../../components/ui/action-menu/furnish-tools'
|
||||
import { tools } from '../../../components/ui/action-menu/structure-tools'
|
||||
import { EDITOR_LAYER } from '../../../lib/constants'
|
||||
import useEditor from '../../../store/use-editor'
|
||||
import { tools } from '../../../components/ui/action-menu/structure-tools'
|
||||
import { furnishTools } from '../../../components/ui/action-menu/furnish-tools'
|
||||
|
||||
interface CursorSphereProps extends Omit<ThreeElements['group'], 'ref'> {
|
||||
color?: string
|
||||
@@ -37,31 +37,49 @@ export const CursorSphere = forwardRef<Group, CursorSphereProps>(function Cursor
|
||||
{/* Flat marker on the ground */}
|
||||
<group rotation={[-Math.PI / 2, 0, 0]}>
|
||||
{/* Center dot */}
|
||||
<mesh renderOrder={2} layers={EDITOR_LAYER}>
|
||||
<mesh layers={EDITOR_LAYER} renderOrder={2}>
|
||||
<circleGeometry args={[0.06, 32]} />
|
||||
<meshBasicMaterial color={color} depthTest={false} depthWrite={false} transparent opacity={0.9} />
|
||||
<meshBasicMaterial
|
||||
color={color}
|
||||
depthTest={false}
|
||||
depthWrite={false}
|
||||
opacity={0.9}
|
||||
transparent
|
||||
/>
|
||||
</mesh>
|
||||
|
||||
|
||||
{/* Outer ring / glow */}
|
||||
<mesh renderOrder={2} layers={EDITOR_LAYER}>
|
||||
<mesh layers={EDITOR_LAYER} renderOrder={2}>
|
||||
<circleGeometry args={[0.2, 32]} />
|
||||
<meshBasicMaterial color={color} depthTest={false} depthWrite={false} transparent opacity={0.25} />
|
||||
<meshBasicMaterial
|
||||
color={color}
|
||||
depthTest={false}
|
||||
depthWrite={false}
|
||||
opacity={0.25}
|
||||
transparent
|
||||
/>
|
||||
</mesh>
|
||||
</group>
|
||||
|
||||
{/* Vertical line */}
|
||||
{height > 0 && (
|
||||
<mesh position={[0, height / 2, 0]} renderOrder={2} layers={EDITOR_LAYER}>
|
||||
<mesh layers={EDITOR_LAYER} position={[0, height / 2, 0]} renderOrder={2}>
|
||||
<cylinderGeometry args={[0.01, 0.01, height, 8]} />
|
||||
<meshBasicMaterial color={color} depthTest={false} depthWrite={false} transparent opacity={0.7} />
|
||||
<meshBasicMaterial
|
||||
color={color}
|
||||
depthTest={false}
|
||||
depthWrite={false}
|
||||
opacity={0.7}
|
||||
transparent
|
||||
/>
|
||||
</mesh>
|
||||
)}
|
||||
|
||||
{/* Tool Icon Tooltip at the top of the line */}
|
||||
{showTooltip && activeToolConfig && (
|
||||
<Html
|
||||
position={[0, height > 0 ? height + 0.2 : 0.6, 0]}
|
||||
center
|
||||
position={[0, height > 0 ? height + 0.2 : 0.6, 0]}
|
||||
style={{
|
||||
pointerEvents: 'none',
|
||||
background: '#18181b', // zinc-900
|
||||
@@ -77,15 +95,15 @@ export const CursorSphere = forwardRef<Group, CursorSphereProps>(function Cursor
|
||||
}}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={activeToolConfig.iconSrc}
|
||||
alt={activeToolConfig.label}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
<img
|
||||
alt={activeToolConfig.label}
|
||||
src={activeToolConfig.iconSrc}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'contain',
|
||||
filter: 'drop-shadow(0px 2px 4px rgba(0,0,0,0.5))'
|
||||
}}
|
||||
filter: 'drop-shadow(0px 2px 4px rgba(0,0,0,0.5))',
|
||||
}}
|
||||
/>
|
||||
</Html>
|
||||
)}
|
||||
|
||||
@@ -237,20 +237,20 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
||||
{/* Border line */}
|
||||
<line
|
||||
// @ts-expect-error R3F <line> element conflicts with SVG <line> type
|
||||
ref={lineRef}
|
||||
frustumCulled={false}
|
||||
renderOrder={10}
|
||||
raycast={() => {}}
|
||||
layers={EDITOR_LAYER}
|
||||
raycast={() => {}}
|
||||
ref={lineRef}
|
||||
renderOrder={10}
|
||||
>
|
||||
<bufferGeometry />
|
||||
<lineBasicNodeMaterial
|
||||
color={color}
|
||||
linewidth={2}
|
||||
depthTest={false}
|
||||
depthWrite={false}
|
||||
transparent
|
||||
linewidth={2}
|
||||
opacity={0.8}
|
||||
transparent
|
||||
/>
|
||||
</line>
|
||||
|
||||
@@ -263,28 +263,9 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
||||
|
||||
return (
|
||||
<mesh
|
||||
layers={EDITOR_LAYER}
|
||||
key={`vertex-${index}`}
|
||||
position={[x!, editY + height / 2, z!]}
|
||||
castShadow
|
||||
onPointerEnter={(e) => {
|
||||
e.stopPropagation()
|
||||
setHoveredVertex(index)
|
||||
}}
|
||||
onPointerLeave={(e) => {
|
||||
e.stopPropagation()
|
||||
setHoveredVertex(null)
|
||||
}}
|
||||
onPointerDown={(e) => {
|
||||
if (e.button !== 0) return
|
||||
e.stopPropagation()
|
||||
setDragState({
|
||||
isDragging: true,
|
||||
vertexIndex: index,
|
||||
initialPosition: [x!, z!],
|
||||
pointerId: e.pointerId,
|
||||
})
|
||||
}}
|
||||
key={`vertex-${index}`}
|
||||
layers={EDITOR_LAYER}
|
||||
onClick={(e) => {
|
||||
if (e.button !== 0) return
|
||||
e.stopPropagation()
|
||||
@@ -296,6 +277,25 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
||||
handleDeleteVertex(index)
|
||||
}
|
||||
}}
|
||||
onPointerDown={(e) => {
|
||||
if (e.button !== 0) return
|
||||
e.stopPropagation()
|
||||
setDragState({
|
||||
isDragging: true,
|
||||
vertexIndex: index,
|
||||
initialPosition: [x!, z!],
|
||||
pointerId: e.pointerId,
|
||||
})
|
||||
}}
|
||||
onPointerEnter={(e) => {
|
||||
e.stopPropagation()
|
||||
setHoveredVertex(index)
|
||||
}}
|
||||
onPointerLeave={(e) => {
|
||||
e.stopPropagation()
|
||||
setHoveredVertex(null)
|
||||
}}
|
||||
position={[x!, editY + height / 2, z!]}
|
||||
>
|
||||
<cylinderGeometry args={[radius, radius, height, 16]} />
|
||||
<meshStandardMaterial
|
||||
@@ -314,16 +314,11 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
||||
|
||||
return (
|
||||
<mesh
|
||||
layers={EDITOR_LAYER}
|
||||
key={`midpoint-${index}`}
|
||||
position={[x!, editY + height / 2, z!]}
|
||||
onPointerEnter={(e) => {
|
||||
layers={EDITOR_LAYER}
|
||||
onClick={(e) => {
|
||||
if (e.button !== 0) return
|
||||
e.stopPropagation()
|
||||
setHoveredMidpoint(index)
|
||||
}}
|
||||
onPointerLeave={(e) => {
|
||||
e.stopPropagation()
|
||||
setHoveredMidpoint(null)
|
||||
}}
|
||||
onPointerDown={(e) => {
|
||||
if (e.button !== 0) return
|
||||
@@ -339,16 +334,21 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
||||
setHoveredMidpoint(null)
|
||||
}
|
||||
}}
|
||||
onClick={(e) => {
|
||||
if (e.button !== 0) return
|
||||
onPointerEnter={(e) => {
|
||||
e.stopPropagation()
|
||||
setHoveredMidpoint(index)
|
||||
}}
|
||||
onPointerLeave={(e) => {
|
||||
e.stopPropagation()
|
||||
setHoveredMidpoint(null)
|
||||
}}
|
||||
position={[x!, editY + height / 2, z!]}
|
||||
>
|
||||
<cylinderGeometry args={[radius, radius, height, 16]} />
|
||||
<meshStandardMaterial
|
||||
color={isHovered ? '#4ade80' : '#22c55e'}
|
||||
transparent
|
||||
opacity={isHovered ? 1 : 0.7}
|
||||
transparent
|
||||
/>
|
||||
</mesh>
|
||||
)
|
||||
|
||||
@@ -29,14 +29,14 @@ export const SiteBoundaryEditor: React.FC = () => {
|
||||
[site, updateNode],
|
||||
)
|
||||
|
||||
if (!site || !site.polygon?.points || site.polygon.points.length < 3) return null
|
||||
if (!(site && site.polygon?.points) || site.polygon.points.length < 3) return null
|
||||
|
||||
return (
|
||||
<PolygonEditor
|
||||
polygon={site.polygon.points}
|
||||
color="#10b981"
|
||||
onPolygonChange={handlePolygonChange}
|
||||
minVertices={3}
|
||||
onPolygonChange={handlePolygonChange}
|
||||
polygon={site.polygon.points}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -27,15 +27,15 @@ export const SlabBoundaryEditor: React.FC<SlabBoundaryEditorProps> = ({ slabId }
|
||||
[slabId, updateNode, setSelection],
|
||||
)
|
||||
|
||||
if (!slab || !slab.polygon || slab.polygon.length < 3) return null
|
||||
if (!(slab && slab.polygon) || slab.polygon.length < 3) return null
|
||||
|
||||
return (
|
||||
<PolygonEditor
|
||||
polygon={slab.polygon}
|
||||
color="#a3a3a3"
|
||||
onPolygonChange={handlePolygonChange}
|
||||
minVertices={3}
|
||||
levelId={resolveLevelId(slab, useScene.getState().nodes)}
|
||||
minVertices={3}
|
||||
onPolygonChange={handlePolygonChange}
|
||||
polygon={slab.polygon}
|
||||
surfaceHeight={slab.elevation ?? 0.05}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -32,15 +32,15 @@ export const SlabHoleEditor: React.FC<SlabHoleEditorProps> = ({ slabId, holeInde
|
||||
[slabId, holeIndex, holes, updateNode, setSelection],
|
||||
)
|
||||
|
||||
if (!slab || !hole || hole.length < 3) return null
|
||||
if (!(slab && hole) || hole.length < 3) return null
|
||||
|
||||
return (
|
||||
<PolygonEditor
|
||||
polygon={hole}
|
||||
color="#ef4444" // red for holes
|
||||
onPolygonChange={handlePolygonChange}
|
||||
color="#ef4444"
|
||||
levelId={resolveLevelId(slab, useScene.getState().nodes)} // red for holes
|
||||
minVertices={3}
|
||||
levelId={resolveLevelId(slab, useScene.getState().nodes)}
|
||||
onPolygonChange={handlePolygonChange}
|
||||
polygon={hole}
|
||||
surfaceHeight={slab.elevation ?? 0.05}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { emitter, type GridEvent, type LevelNode, SlabNode, useScene } from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { BufferGeometry, DoubleSide, type Line, type Group, Shape, Vector3 } from 'three'
|
||||
import { BufferGeometry, DoubleSide, type Group, type Line, Shape, Vector3 } from 'three'
|
||||
import { EDITOR_LAYER } from '../../../lib/constants'
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
import { CursorSphere } from '../shared/cursor-sphere'
|
||||
@@ -35,13 +35,13 @@ const calculateSnapPoint = (
|
||||
// Snap to 45° diagonal
|
||||
const diagonalLength = Math.min(absDx, absDy)
|
||||
return [x1 + Math.sign(dx) * diagonalLength, y1 + Math.sign(dy) * diagonalLength]
|
||||
} else if (minDist === horizontalDist) {
|
||||
}
|
||||
if (minDist === horizontalDist) {
|
||||
// Snap to horizontal
|
||||
return [x, y1]
|
||||
} else {
|
||||
// Snap to vertical
|
||||
return [x1, y]
|
||||
}
|
||||
// Snap to vertical
|
||||
return [x1, y]
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -94,12 +94,19 @@ export const SlabTool: React.FC = () => {
|
||||
|
||||
// Calculate snapped display position (bypass snap when Shift is held)
|
||||
const lastPoint = points[points.length - 1]
|
||||
const displayPoint = (shiftPressed.current || !lastPoint) ? gridPosition : calculateSnapPoint(lastPoint, gridPosition)
|
||||
const displayPoint =
|
||||
shiftPressed.current || !lastPoint
|
||||
? gridPosition
|
||||
: calculateSnapPoint(lastPoint, gridPosition)
|
||||
setSnappedCursorPosition(displayPoint)
|
||||
|
||||
// Play snap sound when the snapped position actually changes (only when drawing)
|
||||
if (points.length > 0 && previousSnappedPointRef.current &&
|
||||
(displayPoint[0] !== previousSnappedPointRef.current[0] || displayPoint[1] !== previousSnappedPointRef.current[1])) {
|
||||
if (
|
||||
points.length > 0 &&
|
||||
previousSnappedPointRef.current &&
|
||||
(displayPoint[0] !== previousSnappedPointRef.current[0] ||
|
||||
displayPoint[1] !== previousSnappedPointRef.current[1])
|
||||
) {
|
||||
sfxEmitter.emit('sfx:grid-snap')
|
||||
}
|
||||
|
||||
@@ -146,8 +153,12 @@ export const SlabTool: React.FC = () => {
|
||||
setPoints([])
|
||||
}
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => { if (e.key === 'Shift') shiftPressed.current = true }
|
||||
const onKeyUp = (e: KeyboardEvent) => { if (e.key === 'Shift') shiftPressed.current = false }
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Shift') shiftPressed.current = true
|
||||
}
|
||||
const onKeyUp = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Shift') shiftPressed.current = false
|
||||
}
|
||||
document.addEventListener('keydown', onKeyDown)
|
||||
document.addEventListener('keyup', onKeyUp)
|
||||
|
||||
@@ -168,7 +179,7 @@ export const SlabTool: React.FC = () => {
|
||||
|
||||
// Update line geometries when points change
|
||||
useEffect(() => {
|
||||
if (!mainLineRef.current || !closingLineRef.current) return
|
||||
if (!(mainLineRef.current && closingLineRef.current)) return
|
||||
|
||||
if (points.length === 0) {
|
||||
mainLineRef.current.visible = false
|
||||
@@ -261,20 +272,32 @@ export const SlabTool: React.FC = () => {
|
||||
|
||||
{/* Main line */}
|
||||
{/* @ts-ignore */}
|
||||
<line ref={mainLineRef} frustumCulled={false} renderOrder={1} visible={false} layers={EDITOR_LAYER}>
|
||||
<line
|
||||
frustumCulled={false}
|
||||
layers={EDITOR_LAYER}
|
||||
ref={mainLineRef}
|
||||
renderOrder={1}
|
||||
visible={false}
|
||||
>
|
||||
<bufferGeometry />
|
||||
<lineBasicNodeMaterial color="#818cf8" linewidth={3} depthTest={false} depthWrite={false} />
|
||||
<lineBasicNodeMaterial color="#818cf8" depthTest={false} depthWrite={false} linewidth={3} />
|
||||
</line>
|
||||
|
||||
{/* Closing line */}
|
||||
{/* @ts-ignore */}
|
||||
<line ref={closingLineRef} frustumCulled={false} renderOrder={1} visible={false} layers={EDITOR_LAYER}>
|
||||
<line
|
||||
frustumCulled={false}
|
||||
layers={EDITOR_LAYER}
|
||||
ref={closingLineRef}
|
||||
renderOrder={1}
|
||||
visible={false}
|
||||
>
|
||||
<bufferGeometry />
|
||||
<lineBasicNodeMaterial
|
||||
color="#818cf8"
|
||||
linewidth={2}
|
||||
depthTest={false}
|
||||
depthWrite={false}
|
||||
linewidth={2}
|
||||
opacity={0.5}
|
||||
transparent
|
||||
/>
|
||||
@@ -282,7 +305,13 @@ export const SlabTool: React.FC = () => {
|
||||
|
||||
{/* Point markers */}
|
||||
{points.map(([x, z], index) => (
|
||||
<CursorSphere key={index} position={[x, levelY + Y_OFFSET + 0.01, z]} color="#818cf8" showTooltip={false} height={0} />
|
||||
<CursorSphere
|
||||
color="#818cf8"
|
||||
height={0}
|
||||
key={index}
|
||||
position={[x, levelY + Y_OFFSET + 0.01, z]}
|
||||
showTooltip={false}
|
||||
/>
|
||||
))}
|
||||
</group>
|
||||
)
|
||||
|
||||
@@ -61,7 +61,9 @@ export const ToolManager: React.FC = () => {
|
||||
|
||||
// Show slab boundary editor when in structure/select mode with a slab selected (but not editing a hole)
|
||||
const showSlabBoundaryEditor =
|
||||
phase === 'structure' && mode === 'select' && selectedSlabId !== undefined &&
|
||||
phase === 'structure' &&
|
||||
mode === 'select' &&
|
||||
selectedSlabId !== undefined &&
|
||||
(!editingHole || editingHole.nodeId !== selectedSlabId)
|
||||
|
||||
// Show slab hole editor when editing a hole on the selected slab
|
||||
@@ -70,12 +72,16 @@ export const ToolManager: React.FC = () => {
|
||||
|
||||
// Show ceiling boundary editor when in structure/select mode with a ceiling selected (but not editing a hole)
|
||||
const showCeilingBoundaryEditor =
|
||||
phase === 'structure' && mode === 'select' && selectedCeilingId !== undefined &&
|
||||
phase === 'structure' &&
|
||||
mode === 'select' &&
|
||||
selectedCeilingId !== undefined &&
|
||||
(!editingHole || editingHole.nodeId !== selectedCeilingId)
|
||||
|
||||
// Show ceiling hole editor when editing a hole on the selected ceiling
|
||||
const showCeilingHoleEditor =
|
||||
selectedCeilingId !== undefined && editingHole !== null && editingHole.nodeId === selectedCeilingId
|
||||
selectedCeilingId !== undefined &&
|
||||
editingHole !== null &&
|
||||
editingHole.nodeId === selectedCeilingId
|
||||
|
||||
// Show zone boundary editor when in structure/select mode with a zone selected
|
||||
// Hide when editing a slab or ceiling to avoid overlapping handles
|
||||
@@ -97,7 +103,7 @@ export const ToolManager: React.FC = () => {
|
||||
{showZoneBoundaryEditor && selectedZoneId && <ZoneBoundaryEditor zoneId={selectedZoneId} />}
|
||||
{showSlabBoundaryEditor && selectedSlabId && <SlabBoundaryEditor slabId={selectedSlabId} />}
|
||||
{showSlabHoleEditor && selectedSlabId && editingHole && (
|
||||
<SlabHoleEditor slabId={selectedSlabId} holeIndex={editingHole.holeIndex} />
|
||||
<SlabHoleEditor holeIndex={editingHole.holeIndex} slabId={selectedSlabId} />
|
||||
)}
|
||||
{showCeilingBoundaryEditor && selectedCeilingId && (
|
||||
<CeilingBoundaryEditor ceilingId={selectedCeilingId} />
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { emitter, type GridEvent, useScene, WallNode } from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { DoubleSide, type Mesh, type Group, Shape, ShapeGeometry, Vector3 } from 'three'
|
||||
import { DoubleSide, type Group, type Mesh, Shape, ShapeGeometry, Vector3 } from 'three'
|
||||
import { EDITOR_LAYER } from '../../../lib/constants'
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
import { CursorSphere } from '../shared/cursor-sphere'
|
||||
@@ -110,7 +110,7 @@ export const WallTool: React.FC = () => {
|
||||
let previousWallEnd: [number, number] | null = null
|
||||
|
||||
const onGridMove = (event: GridEvent) => {
|
||||
if (!cursorRef.current || !wallPreviewRef.current) return
|
||||
if (!(cursorRef.current && wallPreviewRef.current)) return
|
||||
|
||||
gridPosition = [Math.round(event.position[0] * 2) / 2, Math.round(event.position[2] * 2) / 2]
|
||||
const cursorPosition = new Vector3(gridPosition[0], event.position[1], gridPosition[1])
|
||||
@@ -127,8 +127,10 @@ export const WallTool: React.FC = () => {
|
||||
|
||||
// Play snap sound only when the actual wall end position changes
|
||||
const currentWallEnd: [number, number] = [endingPoint.current.x, endingPoint.current.z]
|
||||
if (previousWallEnd &&
|
||||
(currentWallEnd[0] !== previousWallEnd[0] || currentWallEnd[1] !== previousWallEnd[1])) {
|
||||
if (
|
||||
previousWallEnd &&
|
||||
(currentWallEnd[0] !== previousWallEnd[0] || currentWallEnd[1] !== previousWallEnd[1])
|
||||
) {
|
||||
sfxEmitter.emit('sfx:grid-snap')
|
||||
}
|
||||
previousWallEnd = currentWallEnd
|
||||
@@ -196,18 +198,18 @@ export const WallTool: React.FC = () => {
|
||||
return (
|
||||
<group>
|
||||
{/* Cursor indicator */}
|
||||
<CursorSphere ref={cursorRef} />
|
||||
<CursorSphere ref={cursorRef} />
|
||||
|
||||
{/* Wall preview */}
|
||||
<mesh ref={wallPreviewRef} visible={false} renderOrder={1} layers={EDITOR_LAYER}>
|
||||
<mesh layers={EDITOR_LAYER} ref={wallPreviewRef} renderOrder={1} visible={false}>
|
||||
<shapeGeometry />
|
||||
<meshBasicMaterial
|
||||
color="#818cf8"
|
||||
transparent
|
||||
opacity={0.5}
|
||||
side={DoubleSide}
|
||||
depthTest={false}
|
||||
depthWrite={false}
|
||||
opacity={0.5}
|
||||
side={DoubleSide}
|
||||
transparent
|
||||
/>
|
||||
</mesh>
|
||||
</group>
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './window-math'
|
||||
|
||||
const edgeMaterial = new LineBasicNodeMaterial({
|
||||
color: 0xef4444,
|
||||
color: 0xef_44_44,
|
||||
linewidth: 3,
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
@@ -52,9 +52,10 @@ export const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWin
|
||||
useEffect(() => {
|
||||
useScene.temporal.getState().pause()
|
||||
|
||||
const meta = (typeof movingWindowNode.metadata === 'object' && movingWindowNode.metadata !== null)
|
||||
? movingWindowNode.metadata as Record<string, unknown>
|
||||
: {}
|
||||
const meta =
|
||||
typeof movingWindowNode.metadata === 'object' && movingWindowNode.metadata !== null
|
||||
? (movingWindowNode.metadata as Record<string, unknown>)
|
||||
: {}
|
||||
const isNew = !!meta.isNew
|
||||
|
||||
// Save original state (only used in move mode)
|
||||
@@ -106,7 +107,7 @@ export const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWin
|
||||
group.visible = true
|
||||
group.position.set(...worldPosition)
|
||||
group.rotation.y = cursorRotationY
|
||||
edgeMaterial.color.setHex(valid ? 0x22c55e : 0xef4444)
|
||||
edgeMaterial.color.setHex(valid ? 0x22_c5_5e : 0xef_44_44)
|
||||
}
|
||||
|
||||
const onWallEnter = (event: WallEvent) => {
|
||||
@@ -121,8 +122,11 @@ export const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWin
|
||||
const localX = snapToHalf(event.localPosition[0])
|
||||
const localY = snapToHalf(event.localPosition[1])
|
||||
const { clampedX, clampedY } = clampToWall(
|
||||
event.node, localX, localY,
|
||||
movingWindowNode.width, movingWindowNode.height,
|
||||
event.node,
|
||||
localX,
|
||||
localY,
|
||||
movingWindowNode.width,
|
||||
movingWindowNode.height,
|
||||
)
|
||||
|
||||
const prevWallId = currentWallId
|
||||
@@ -140,13 +144,22 @@ export const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWin
|
||||
markWallDirty(event.node.id)
|
||||
|
||||
const valid = !hasWallChildOverlap(
|
||||
event.node.id, clampedX, clampedY,
|
||||
movingWindowNode.width, movingWindowNode.height,
|
||||
event.node.id,
|
||||
clampedX,
|
||||
clampedY,
|
||||
movingWindowNode.width,
|
||||
movingWindowNode.height,
|
||||
movingWindowNode.id,
|
||||
)
|
||||
|
||||
updateCursor(
|
||||
wallLocalToWorld(event.node, clampedX, clampedY, getLevelYOffset(), getSlabElevation(event)),
|
||||
wallLocalToWorld(
|
||||
event.node,
|
||||
clampedX,
|
||||
clampedY,
|
||||
getLevelYOffset(),
|
||||
getSlabElevation(event),
|
||||
),
|
||||
cursorRotation,
|
||||
valid,
|
||||
)
|
||||
@@ -165,8 +178,11 @@ export const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWin
|
||||
const localX = snapToHalf(event.localPosition[0])
|
||||
const localY = snapToHalf(event.localPosition[1])
|
||||
const { clampedX, clampedY } = clampToWall(
|
||||
event.node, localX, localY,
|
||||
movingWindowNode.width, movingWindowNode.height,
|
||||
event.node,
|
||||
localX,
|
||||
localY,
|
||||
movingWindowNode.width,
|
||||
movingWindowNode.height,
|
||||
)
|
||||
|
||||
useScene.getState().updateNode(movingWindowNode.id, {
|
||||
@@ -184,13 +200,22 @@ export const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWin
|
||||
markWallDirty(event.node.id)
|
||||
|
||||
const valid = !hasWallChildOverlap(
|
||||
event.node.id, clampedX, clampedY,
|
||||
movingWindowNode.width, movingWindowNode.height,
|
||||
event.node.id,
|
||||
clampedX,
|
||||
clampedY,
|
||||
movingWindowNode.width,
|
||||
movingWindowNode.height,
|
||||
movingWindowNode.id,
|
||||
)
|
||||
|
||||
updateCursor(
|
||||
wallLocalToWorld(event.node, clampedX, clampedY, getLevelYOffset(), getSlabElevation(event)),
|
||||
wallLocalToWorld(
|
||||
event.node,
|
||||
clampedX,
|
||||
clampedY,
|
||||
getLevelYOffset(),
|
||||
getSlabElevation(event),
|
||||
),
|
||||
cursorRotation,
|
||||
valid,
|
||||
)
|
||||
@@ -208,13 +233,19 @@ export const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWin
|
||||
const localX = snapToHalf(event.localPosition[0])
|
||||
const localY = snapToHalf(event.localPosition[1])
|
||||
const { clampedX, clampedY } = clampToWall(
|
||||
event.node, localX, localY,
|
||||
movingWindowNode.width, movingWindowNode.height,
|
||||
event.node,
|
||||
localX,
|
||||
localY,
|
||||
movingWindowNode.width,
|
||||
movingWindowNode.height,
|
||||
)
|
||||
|
||||
const valid = !hasWallChildOverlap(
|
||||
event.node.id, clampedX, clampedY,
|
||||
movingWindowNode.width, movingWindowNode.height,
|
||||
event.node.id,
|
||||
clampedX,
|
||||
clampedY,
|
||||
movingWindowNode.width,
|
||||
movingWindowNode.height,
|
||||
movingWindowNode.id,
|
||||
)
|
||||
if (!valid) return
|
||||
@@ -331,7 +362,9 @@ export const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWin
|
||||
|
||||
return () => {
|
||||
// Safety cleanup: if still transient on unmount (e.g. phase switch mid-move)
|
||||
const current = useScene.getState().nodes[movingWindowNode.id as AnyNodeId] as WindowNode | undefined
|
||||
const current = useScene.getState().nodes[movingWindowNode.id as AnyNodeId] as
|
||||
| WindowNode
|
||||
| undefined
|
||||
const currentMeta = current?.metadata as Record<string, unknown> | undefined
|
||||
if (currentMeta?.isTransient) {
|
||||
if (isNew) {
|
||||
@@ -371,7 +404,7 @@ export const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWin
|
||||
|
||||
return (
|
||||
<group ref={cursorGroupRef} visible={false}>
|
||||
<lineSegments geometry={edgesGeo} material={edgeMaterial} layers={EDITOR_LAYER} />
|
||||
<lineSegments geometry={edgesGeo} layers={EDITOR_LAYER} material={edgeMaterial} />
|
||||
</group>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import { type AnyNodeId, type DoorNode, getScaledDimensions, type ItemNode, useScene, type WallNode, type WindowNode } from '@pascal-app/core'
|
||||
import {
|
||||
type AnyNodeId,
|
||||
type DoorNode,
|
||||
getScaledDimensions,
|
||||
type ItemNode,
|
||||
useScene,
|
||||
type WallNode,
|
||||
type WindowNode,
|
||||
} from '@pascal-app/core'
|
||||
|
||||
/**
|
||||
* Converts wall-local (X along wall, Y = height above wall base) to world XYZ.
|
||||
@@ -82,19 +90,19 @@ export function hasWallChildOverlap(
|
||||
const [w, h] = getScaledDimensions(item)
|
||||
childLeft = item.position[0] - w / 2
|
||||
childRight = item.position[0] + w / 2
|
||||
childBottom = item.position[1] // items store bottom Y
|
||||
childBottom = item.position[1] // items store bottom Y
|
||||
childTop = item.position[1] + h
|
||||
} else if (child.type === 'window') {
|
||||
const win = child as WindowNode
|
||||
childLeft = win.position[0] - win.width / 2
|
||||
childRight = win.position[0] + win.width / 2
|
||||
childBottom = win.position[1] - win.height / 2 // windows store center Y
|
||||
childBottom = win.position[1] - win.height / 2 // windows store center Y
|
||||
childTop = win.position[1] + win.height / 2
|
||||
} else if (child.type === 'door') {
|
||||
const door = child as DoorNode
|
||||
childLeft = door.position[0] - door.width / 2
|
||||
childRight = door.position[0] + door.width / 2
|
||||
childBottom = door.position[1] - door.height / 2 // doors store center Y
|
||||
childBottom = door.position[1] - door.height / 2 // doors store center Y
|
||||
childTop = door.position[1] + door.height / 2
|
||||
} else {
|
||||
continue
|
||||
|
||||
@@ -11,6 +11,8 @@ import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { BoxGeometry, EdgesGeometry, type Group, type LineSegments } from 'three'
|
||||
import { LineBasicNodeMaterial } from 'three/webgpu'
|
||||
import { EDITOR_LAYER } from '../../../lib/constants'
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
import {
|
||||
calculateCursorRotation,
|
||||
calculateItemRotation,
|
||||
@@ -19,12 +21,10 @@ import {
|
||||
snapToHalf,
|
||||
} from '../item/placement-math'
|
||||
import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './window-math'
|
||||
import { EDITOR_LAYER } from '../../../lib/constants'
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
|
||||
// Shared edge material — reuse across renders, just toggle color
|
||||
const edgeMaterial = new LineBasicNodeMaterial({
|
||||
color: 0xef4444, // red-500 default (invalid)
|
||||
color: 0xef_44_44, // red-500 default (invalid)
|
||||
linewidth: 3,
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
@@ -81,7 +81,7 @@ export const WindowTool: React.FC = () => {
|
||||
group.visible = true
|
||||
group.position.set(...worldPosition)
|
||||
group.rotation.y = cursorRotationY
|
||||
edgeMaterial.color.setHex(valid ? 0x22c55e : 0xef4444)
|
||||
edgeMaterial.color.setHex(valid ? 0x22_c5_5e : 0xef_44_44)
|
||||
}
|
||||
|
||||
const onWallEnter = (event: WallEvent) => {
|
||||
@@ -120,7 +120,13 @@ export const WindowTool: React.FC = () => {
|
||||
const valid = !hasWallChildOverlap(event.node.id, clampedX, clampedY, width, height, node.id)
|
||||
|
||||
updateCursor(
|
||||
wallLocalToWorld(event.node, clampedX, clampedY, getLevelYOffset(), getSlabElevation(event)),
|
||||
wallLocalToWorld(
|
||||
event.node,
|
||||
clampedX,
|
||||
clampedY,
|
||||
getLevelYOffset(),
|
||||
getSlabElevation(event),
|
||||
),
|
||||
cursorRotation,
|
||||
valid,
|
||||
)
|
||||
@@ -155,12 +161,22 @@ export const WindowTool: React.FC = () => {
|
||||
}
|
||||
|
||||
const valid = !hasWallChildOverlap(
|
||||
event.node.id, clampedX, clampedY, width, height,
|
||||
event.node.id,
|
||||
clampedX,
|
||||
clampedY,
|
||||
width,
|
||||
height,
|
||||
draftRef.current?.id,
|
||||
)
|
||||
|
||||
updateCursor(
|
||||
wallLocalToWorld(event.node, clampedX, clampedY, getLevelYOffset(), getSlabElevation(event)),
|
||||
wallLocalToWorld(
|
||||
event.node,
|
||||
clampedX,
|
||||
clampedY,
|
||||
getLevelYOffset(),
|
||||
getSlabElevation(event),
|
||||
),
|
||||
cursorRotation,
|
||||
valid,
|
||||
)
|
||||
@@ -179,12 +195,18 @@ export const WindowTool: React.FC = () => {
|
||||
const localX = snapToHalf(event.localPosition[0])
|
||||
const localY = snapToHalf(event.localPosition[1])
|
||||
const { clampedX, clampedY } = clampToWall(
|
||||
event.node, localX, localY,
|
||||
draftRef.current.width, draftRef.current.height,
|
||||
event.node,
|
||||
localX,
|
||||
localY,
|
||||
draftRef.current.width,
|
||||
draftRef.current.height,
|
||||
)
|
||||
const valid = !hasWallChildOverlap(
|
||||
event.node.id, clampedX, clampedY,
|
||||
draftRef.current.width, draftRef.current.height,
|
||||
event.node.id,
|
||||
clampedX,
|
||||
clampedY,
|
||||
draftRef.current.width,
|
||||
draftRef.current.height,
|
||||
draftRef.current.id,
|
||||
)
|
||||
if (!valid) return
|
||||
@@ -270,7 +292,12 @@ export const WindowTool: React.FC = () => {
|
||||
|
||||
return (
|
||||
<group ref={cursorGroupRef} visible={false}>
|
||||
<lineSegments ref={edgesRef} geometry={edgesGeo} material={edgeMaterial} layers={EDITOR_LAYER} />
|
||||
<lineSegments
|
||||
geometry={edgesGeo}
|
||||
layers={EDITOR_LAYER}
|
||||
material={edgeMaterial}
|
||||
ref={edgesRef}
|
||||
/>
|
||||
</group>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -23,17 +23,17 @@ export const ZoneBoundaryEditor: React.FC<ZoneBoundaryEditorProps> = ({ zoneId }
|
||||
[zoneId, updateNode],
|
||||
)
|
||||
|
||||
if (!zone || !zone.polygon || zone.polygon.length < 3) return null
|
||||
if (!(zone && zone.polygon) || zone.polygon.length < 3) return null
|
||||
|
||||
const zoneColor = zone.color || '#3b82f6'
|
||||
|
||||
return (
|
||||
<PolygonEditor
|
||||
polygon={zone.polygon}
|
||||
color={zoneColor}
|
||||
onPolygonChange={handlePolygonChange}
|
||||
minVertices={3}
|
||||
levelId={resolveLevelId(zone, useScene.getState().nodes)}
|
||||
minVertices={3}
|
||||
onPolygonChange={handlePolygonChange}
|
||||
polygon={zone.polygon}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,222 +1,212 @@
|
||||
import { emitter, type GridEvent, useScene, ZoneNode, type LevelNode } from "@pascal-app/core";
|
||||
import { useViewer } from "@pascal-app/viewer";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { BufferGeometry, DoubleSide, type Line, type Group, Shape, Vector3 } from "three";
|
||||
import { EDITOR_LAYER } from "./../../../lib/constants";
|
||||
import useEditor from "./../../../store/use-editor";
|
||||
import { CursorSphere } from "../shared/cursor-sphere";
|
||||
import { PALETTE_COLORS } from "./../../../components/ui/primitives/color-dot";
|
||||
import { emitter, type GridEvent, type LevelNode, useScene, ZoneNode } from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { BufferGeometry, DoubleSide, type Group, type Line, Shape, Vector3 } from 'three'
|
||||
import { PALETTE_COLORS } from './../../../components/ui/primitives/color-dot'
|
||||
import { EDITOR_LAYER } from './../../../lib/constants'
|
||||
import useEditor from './../../../store/use-editor'
|
||||
import { CursorSphere } from '../shared/cursor-sphere'
|
||||
|
||||
const Y_OFFSET = 0.02;
|
||||
const Y_OFFSET = 0.02
|
||||
|
||||
/**
|
||||
* Snaps a point to the nearest axis-aligned or 45-degree diagonal from the last point
|
||||
*/
|
||||
const calculateSnapPoint = (
|
||||
lastPoint: [number, number],
|
||||
currentPoint: [number, number]
|
||||
currentPoint: [number, number],
|
||||
): [number, number] => {
|
||||
const [x1, y1] = lastPoint;
|
||||
const [x, y] = currentPoint;
|
||||
const [x1, y1] = lastPoint
|
||||
const [x, y] = currentPoint
|
||||
|
||||
const dx = x - x1;
|
||||
const dy = y - y1;
|
||||
const absDx = Math.abs(dx);
|
||||
const absDy = Math.abs(dy);
|
||||
const dx = x - x1
|
||||
const dy = y - y1
|
||||
const absDx = Math.abs(dx)
|
||||
const absDy = Math.abs(dy)
|
||||
|
||||
// Calculate distances to horizontal, vertical, and diagonal lines
|
||||
const horizontalDist = absDy;
|
||||
const verticalDist = absDx;
|
||||
const diagonalDist = Math.abs(absDx - absDy);
|
||||
const horizontalDist = absDy
|
||||
const verticalDist = absDx
|
||||
const diagonalDist = Math.abs(absDx - absDy)
|
||||
|
||||
// Find the minimum distance to determine which axis to snap to
|
||||
const minDist = Math.min(horizontalDist, verticalDist, diagonalDist);
|
||||
const minDist = Math.min(horizontalDist, verticalDist, diagonalDist)
|
||||
|
||||
if (minDist === diagonalDist) {
|
||||
// Snap to 45° diagonal
|
||||
const diagonalLength = Math.min(absDx, absDy);
|
||||
return [
|
||||
x1 + Math.sign(dx) * diagonalLength,
|
||||
y1 + Math.sign(dy) * diagonalLength,
|
||||
];
|
||||
} else if (minDist === horizontalDist) {
|
||||
// Snap to horizontal
|
||||
return [x, y1];
|
||||
} else {
|
||||
// Snap to vertical
|
||||
return [x1, y];
|
||||
const diagonalLength = Math.min(absDx, absDy)
|
||||
return [x1 + Math.sign(dx) * diagonalLength, y1 + Math.sign(dy) * diagonalLength]
|
||||
}
|
||||
};
|
||||
if (minDist === horizontalDist) {
|
||||
// Snap to horizontal
|
||||
return [x, y1]
|
||||
}
|
||||
// Snap to vertical
|
||||
return [x1, y]
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a zone with the given polygon points
|
||||
*/
|
||||
const commitZoneDrawing = (
|
||||
levelId: LevelNode["id"],
|
||||
points: Array<[number, number]>
|
||||
) => {
|
||||
const { createNode, nodes } = useScene.getState();
|
||||
const commitZoneDrawing = (levelId: LevelNode['id'], points: Array<[number, number]>) => {
|
||||
const { createNode, nodes } = useScene.getState()
|
||||
|
||||
// Count existing zones for naming and color cycling
|
||||
const zoneCount = Object.values(nodes).filter((n) => n.type === "zone").length;
|
||||
const name = `Zone ${zoneCount + 1}`;
|
||||
const zoneCount = Object.values(nodes).filter((n) => n.type === 'zone').length
|
||||
const name = `Zone ${zoneCount + 1}`
|
||||
|
||||
// Cycle through colors
|
||||
const color = PALETTE_COLORS[zoneCount % PALETTE_COLORS.length];
|
||||
const color = PALETTE_COLORS[zoneCount % PALETTE_COLORS.length]
|
||||
|
||||
const zone = ZoneNode.parse({
|
||||
name,
|
||||
polygon: points,
|
||||
color,
|
||||
});
|
||||
})
|
||||
|
||||
createNode(zone, levelId);
|
||||
createNode(zone, levelId)
|
||||
|
||||
// Select the newly created zone
|
||||
useViewer.getState().setSelection({ zoneId: zone.id });
|
||||
};
|
||||
useViewer.getState().setSelection({ zoneId: zone.id })
|
||||
}
|
||||
|
||||
type PreviewState = {
|
||||
points: Array<[number, number]>;
|
||||
cursorPoint: [number, number] | null;
|
||||
levelY: number;
|
||||
};
|
||||
points: Array<[number, number]>
|
||||
cursorPoint: [number, number] | null
|
||||
levelY: number
|
||||
}
|
||||
|
||||
// Helper to validate point values (no NaN or Infinity)
|
||||
const isValidPoint = (
|
||||
pt: [number, number] | null | undefined
|
||||
): pt is [number, number] => {
|
||||
if (!pt) return false;
|
||||
return Number.isFinite(pt[0]) && Number.isFinite(pt[1]);
|
||||
};
|
||||
const isValidPoint = (pt: [number, number] | null | undefined): pt is [number, number] => {
|
||||
if (!pt) return false
|
||||
return Number.isFinite(pt[0]) && Number.isFinite(pt[1])
|
||||
}
|
||||
|
||||
export const ZoneTool: React.FC = () => {
|
||||
const cursorRef = useRef<Group>(null);
|
||||
const mainLineRef = useRef<Line>(null!);
|
||||
const closingLineRef = useRef<Line>(null!);
|
||||
const pointsRef = useRef<Array<[number, number]>>([]);
|
||||
const levelYRef = useRef(0); // Track current level Y position
|
||||
const currentLevelId = useViewer((state) => state.selection.levelId);
|
||||
const setTool = useEditor((state) => state.setTool);
|
||||
const cursorRef = useRef<Group>(null)
|
||||
const mainLineRef = useRef<Line>(null!)
|
||||
const closingLineRef = useRef<Line>(null!)
|
||||
const pointsRef = useRef<Array<[number, number]>>([])
|
||||
const levelYRef = useRef(0) // Track current level Y position
|
||||
const currentLevelId = useViewer((state) => state.selection.levelId)
|
||||
const setTool = useEditor((state) => state.setTool)
|
||||
|
||||
// Preview state for reactive rendering (for shape and point markers)
|
||||
const [preview, setPreview] = useState<PreviewState>({
|
||||
points: [],
|
||||
cursorPoint: null,
|
||||
levelY: 0,
|
||||
});
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentLevelId) return;
|
||||
if (!currentLevelId) return
|
||||
|
||||
let cursorPosition: [number, number] = [0, 0];
|
||||
let cursorPosition: [number, number] = [0, 0]
|
||||
|
||||
// Initialize line geometries
|
||||
mainLineRef.current.geometry = new BufferGeometry();
|
||||
closingLineRef.current.geometry = new BufferGeometry();
|
||||
mainLineRef.current.geometry = new BufferGeometry()
|
||||
closingLineRef.current.geometry = new BufferGeometry()
|
||||
|
||||
const updateLines = () => {
|
||||
const points = pointsRef.current;
|
||||
const y = levelYRef.current + Y_OFFSET;
|
||||
const points = pointsRef.current
|
||||
const y = levelYRef.current + Y_OFFSET
|
||||
|
||||
if (points.length === 0) {
|
||||
mainLineRef.current.visible = false;
|
||||
closingLineRef.current.visible = false;
|
||||
return;
|
||||
mainLineRef.current.visible = false
|
||||
closingLineRef.current.visible = false
|
||||
return
|
||||
}
|
||||
|
||||
// Build main line points
|
||||
const linePoints: Vector3[] = points.map(
|
||||
([x, z]) => new Vector3(x, y, z)
|
||||
);
|
||||
const linePoints: Vector3[] = points.map(([x, z]) => new Vector3(x, y, z))
|
||||
|
||||
// Add cursor point
|
||||
const lastPoint = points[points.length - 1];
|
||||
const lastPoint = points[points.length - 1]
|
||||
if (lastPoint) {
|
||||
const snapped = calculateSnapPoint(lastPoint, cursorPosition);
|
||||
const snapped = calculateSnapPoint(lastPoint, cursorPosition)
|
||||
if (isValidPoint(snapped)) {
|
||||
linePoints.push(new Vector3(snapped[0], y, snapped[1]));
|
||||
linePoints.push(new Vector3(snapped[0], y, snapped[1]))
|
||||
}
|
||||
}
|
||||
|
||||
// Update main line geometry
|
||||
if (linePoints.length >= 2) {
|
||||
mainLineRef.current.geometry.dispose();
|
||||
mainLineRef.current.geometry = new BufferGeometry().setFromPoints(linePoints);
|
||||
mainLineRef.current.visible = true;
|
||||
mainLineRef.current.geometry.dispose()
|
||||
mainLineRef.current.geometry = new BufferGeometry().setFromPoints(linePoints)
|
||||
mainLineRef.current.visible = true
|
||||
} else {
|
||||
mainLineRef.current.visible = false;
|
||||
mainLineRef.current.visible = false
|
||||
}
|
||||
|
||||
// Update closing line (from cursor back to first point)
|
||||
const firstPoint = points[0];
|
||||
const firstPoint = points[0]
|
||||
if (points.length >= 2 && lastPoint && isValidPoint(firstPoint)) {
|
||||
const snapped = calculateSnapPoint(lastPoint, cursorPosition);
|
||||
const snapped = calculateSnapPoint(lastPoint, cursorPosition)
|
||||
if (isValidPoint(snapped)) {
|
||||
const closingPoints = [
|
||||
new Vector3(snapped[0], y, snapped[1]),
|
||||
new Vector3(firstPoint[0], y, firstPoint[1]),
|
||||
];
|
||||
closingLineRef.current.geometry.dispose();
|
||||
closingLineRef.current.geometry = new BufferGeometry().setFromPoints(closingPoints);
|
||||
closingLineRef.current.visible = true;
|
||||
]
|
||||
closingLineRef.current.geometry.dispose()
|
||||
closingLineRef.current.geometry = new BufferGeometry().setFromPoints(closingPoints)
|
||||
closingLineRef.current.visible = true
|
||||
}
|
||||
} else {
|
||||
closingLineRef.current.visible = false;
|
||||
closingLineRef.current.visible = false
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const updatePreview = () => {
|
||||
const points = pointsRef.current;
|
||||
const lastPoint = points[points.length - 1];
|
||||
const points = pointsRef.current
|
||||
const lastPoint = points[points.length - 1]
|
||||
|
||||
let cursorPt: [number, number] | null = null;
|
||||
let cursorPt: [number, number] | null = null
|
||||
if (lastPoint) {
|
||||
cursorPt = calculateSnapPoint(lastPoint, cursorPosition);
|
||||
cursorPt = calculateSnapPoint(lastPoint, cursorPosition)
|
||||
} else if (points.length === 0) {
|
||||
cursorPt = cursorPosition;
|
||||
cursorPt = cursorPosition
|
||||
}
|
||||
|
||||
setPreview({ points: [...points], cursorPoint: cursorPt, levelY: levelYRef.current });
|
||||
updateLines();
|
||||
};
|
||||
setPreview({ points: [...points], cursorPoint: cursorPt, levelY: levelYRef.current })
|
||||
updateLines()
|
||||
}
|
||||
|
||||
const onGridMove = (event: GridEvent) => {
|
||||
if (!cursorRef.current) return;
|
||||
if (!cursorRef.current) return
|
||||
|
||||
// Snap to 0.5 grid
|
||||
const gridX = Math.round(event.position[0] * 2) / 2;
|
||||
const gridZ = Math.round(event.position[2] * 2) / 2;
|
||||
cursorPosition = [gridX, gridZ];
|
||||
levelYRef.current = event.position[1];
|
||||
const gridX = Math.round(event.position[0] * 2) / 2
|
||||
const gridZ = Math.round(event.position[2] * 2) / 2
|
||||
cursorPosition = [gridX, gridZ]
|
||||
levelYRef.current = event.position[1]
|
||||
|
||||
// If we have points, snap to axis from last point
|
||||
const lastPoint = pointsRef.current[pointsRef.current.length - 1];
|
||||
const lastPoint = pointsRef.current[pointsRef.current.length - 1]
|
||||
if (lastPoint) {
|
||||
const snapped = calculateSnapPoint(lastPoint, cursorPosition);
|
||||
cursorRef.current.position.set(snapped[0], event.position[1], snapped[1]);
|
||||
const snapped = calculateSnapPoint(lastPoint, cursorPosition)
|
||||
cursorRef.current.position.set(snapped[0], event.position[1], snapped[1])
|
||||
} else {
|
||||
cursorRef.current.position.set(gridX, event.position[1], gridZ);
|
||||
cursorRef.current.position.set(gridX, event.position[1], gridZ)
|
||||
}
|
||||
|
||||
updatePreview();
|
||||
};
|
||||
updatePreview()
|
||||
}
|
||||
|
||||
const onGridClick = (event: GridEvent) => {
|
||||
if (!currentLevelId) return;
|
||||
if (!currentLevelId) return
|
||||
|
||||
const gridX = Math.round(event.position[0] * 2) / 2;
|
||||
const gridZ = Math.round(event.position[2] * 2) / 2;
|
||||
let clickPoint: [number, number] = [gridX, gridZ];
|
||||
const gridX = Math.round(event.position[0] * 2) / 2
|
||||
const gridZ = Math.round(event.position[2] * 2) / 2
|
||||
let clickPoint: [number, number] = [gridX, gridZ]
|
||||
|
||||
// Snap to axis from last point
|
||||
const lastPoint = pointsRef.current[pointsRef.current.length - 1];
|
||||
const lastPoint = pointsRef.current[pointsRef.current.length - 1]
|
||||
if (lastPoint) {
|
||||
clickPoint = calculateSnapPoint(lastPoint, clickPoint);
|
||||
clickPoint = calculateSnapPoint(lastPoint, clickPoint)
|
||||
}
|
||||
|
||||
// Check if clicking on the first point to close the shape
|
||||
const firstPoint = pointsRef.current[0];
|
||||
const firstPoint = pointsRef.current[0]
|
||||
if (
|
||||
pointsRef.current.length >= 3 &&
|
||||
firstPoint &&
|
||||
@@ -224,80 +214,80 @@ export const ZoneTool: React.FC = () => {
|
||||
Math.abs(clickPoint[1] - firstPoint[1]) < 0.25
|
||||
) {
|
||||
// Create the zone
|
||||
commitZoneDrawing(currentLevelId, pointsRef.current);
|
||||
commitZoneDrawing(currentLevelId, pointsRef.current)
|
||||
|
||||
// Reset state
|
||||
pointsRef.current = [];
|
||||
setPreview({ points: [], cursorPoint: null, levelY: levelYRef.current });
|
||||
mainLineRef.current.visible = false;
|
||||
closingLineRef.current.visible = false;
|
||||
pointsRef.current = []
|
||||
setPreview({ points: [], cursorPoint: null, levelY: levelYRef.current })
|
||||
mainLineRef.current.visible = false
|
||||
closingLineRef.current.visible = false
|
||||
} else {
|
||||
// Add point to polygon
|
||||
pointsRef.current = [...pointsRef.current, clickPoint];
|
||||
updatePreview();
|
||||
pointsRef.current = [...pointsRef.current, clickPoint]
|
||||
updatePreview()
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const onGridDoubleClick = (_event: GridEvent) => {
|
||||
if (!currentLevelId) return;
|
||||
if (!currentLevelId) return
|
||||
|
||||
// Need at least 3 points to form a polygon
|
||||
if (pointsRef.current.length >= 3) {
|
||||
commitZoneDrawing(currentLevelId, pointsRef.current);
|
||||
commitZoneDrawing(currentLevelId, pointsRef.current)
|
||||
|
||||
// Reset state
|
||||
pointsRef.current = [];
|
||||
setPreview({ points: [], cursorPoint: null, levelY: levelYRef.current });
|
||||
mainLineRef.current.visible = false;
|
||||
closingLineRef.current.visible = false;
|
||||
pointsRef.current = []
|
||||
setPreview({ points: [], cursorPoint: null, levelY: levelYRef.current })
|
||||
mainLineRef.current.visible = false
|
||||
closingLineRef.current.visible = false
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Subscribe to events
|
||||
emitter.on("grid:move", onGridMove);
|
||||
emitter.on("grid:click", onGridClick);
|
||||
emitter.on("grid:double-click", onGridDoubleClick);
|
||||
emitter.on('grid:move', onGridMove)
|
||||
emitter.on('grid:click', onGridClick)
|
||||
emitter.on('grid:double-click', onGridDoubleClick)
|
||||
|
||||
return () => {
|
||||
emitter.off("grid:move", onGridMove);
|
||||
emitter.off("grid:click", onGridClick);
|
||||
emitter.off("grid:double-click", onGridDoubleClick);
|
||||
emitter.off('grid:move', onGridMove)
|
||||
emitter.off('grid:click', onGridClick)
|
||||
emitter.off('grid:double-click', onGridDoubleClick)
|
||||
|
||||
// Reset state on unmount
|
||||
pointsRef.current = [];
|
||||
};
|
||||
}, [currentLevelId, setTool]);
|
||||
pointsRef.current = []
|
||||
}
|
||||
}, [currentLevelId, setTool])
|
||||
|
||||
const { points, cursorPoint, levelY } = preview;
|
||||
const { points, cursorPoint, levelY } = preview
|
||||
|
||||
// Create preview shape when we have 3+ points
|
||||
const previewShape = useMemo(() => {
|
||||
if (points.length < 3) return null;
|
||||
if (points.length < 3) return null
|
||||
|
||||
const allPoints = [...points];
|
||||
const allPoints = [...points]
|
||||
if (isValidPoint(cursorPoint)) {
|
||||
allPoints.push(cursorPoint);
|
||||
allPoints.push(cursorPoint)
|
||||
}
|
||||
|
||||
// THREE.Shape is in X-Y plane. After rotation of -PI/2 around X:
|
||||
// - Shape X -> World X
|
||||
// - Shape Y -> World -Z (so we negate Z to get correct orientation)
|
||||
const firstPt = allPoints[0];
|
||||
if (!isValidPoint(firstPt)) return null;
|
||||
const firstPt = allPoints[0]
|
||||
if (!isValidPoint(firstPt)) return null
|
||||
|
||||
const shape = new Shape();
|
||||
shape.moveTo(firstPt[0], -firstPt[1]);
|
||||
const shape = new Shape()
|
||||
shape.moveTo(firstPt[0], -firstPt[1])
|
||||
|
||||
for (let i = 1; i < allPoints.length; i++) {
|
||||
const pt = allPoints[i];
|
||||
const pt = allPoints[i]
|
||||
if (isValidPoint(pt)) {
|
||||
shape.lineTo(pt[0], -pt[1]);
|
||||
shape.lineTo(pt[0], -pt[1])
|
||||
}
|
||||
}
|
||||
shape.closePath();
|
||||
shape.closePath()
|
||||
|
||||
return shape;
|
||||
}, [points, cursorPoint]);
|
||||
return shape
|
||||
}, [points, cursorPoint])
|
||||
|
||||
return (
|
||||
<group>
|
||||
@@ -325,25 +315,32 @@ export const ZoneTool: React.FC = () => {
|
||||
|
||||
{/* Main line - uses native line element with TSL-compatible material */}
|
||||
{/* @ts-ignore */}
|
||||
<line ref={mainLineRef} frustumCulled={false} renderOrder={1} visible={false} layers={EDITOR_LAYER}>
|
||||
<line
|
||||
frustumCulled={false}
|
||||
layers={EDITOR_LAYER}
|
||||
ref={mainLineRef}
|
||||
renderOrder={1}
|
||||
visible={false}
|
||||
>
|
||||
<bufferGeometry />
|
||||
<lineBasicNodeMaterial
|
||||
color="#818cf8"
|
||||
linewidth={3}
|
||||
depthTest={false}
|
||||
depthWrite={false}
|
||||
/>
|
||||
<lineBasicNodeMaterial color="#818cf8" depthTest={false} depthWrite={false} linewidth={3} />
|
||||
</line>
|
||||
|
||||
{/* Closing line - uses native line element with TSL-compatible material */}
|
||||
{/* @ts-ignore */}
|
||||
<line ref={closingLineRef} frustumCulled={false} renderOrder={1} visible={false} layers={EDITOR_LAYER}>
|
||||
<line
|
||||
frustumCulled={false}
|
||||
layers={EDITOR_LAYER}
|
||||
ref={closingLineRef}
|
||||
renderOrder={1}
|
||||
visible={false}
|
||||
>
|
||||
<bufferGeometry />
|
||||
<lineBasicNodeMaterial
|
||||
color="#818cf8"
|
||||
linewidth={2}
|
||||
depthTest={false}
|
||||
depthWrite={false}
|
||||
linewidth={2}
|
||||
opacity={0.5}
|
||||
transparent
|
||||
/>
|
||||
@@ -352,9 +349,15 @@ export const ZoneTool: React.FC = () => {
|
||||
{/* Point markers */}
|
||||
{points.map(([x, z], index) =>
|
||||
isValidPoint([x, z]) ? (
|
||||
<CursorSphere key={index} position={[x, levelY + Y_OFFSET + 0.01, z]} color="#818cf8" showTooltip={false} height={0} />
|
||||
) : null
|
||||
<CursorSphere
|
||||
color="#818cf8"
|
||||
height={0}
|
||||
key={index}
|
||||
position={[x, levelY + Y_OFFSET + 0.01, z]}
|
||||
showTooltip={false}
|
||||
/>
|
||||
) : null,
|
||||
)}
|
||||
</group>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,47 +1,44 @@
|
||||
import * as React from "react";
|
||||
import { Button } from "./../../../components/ui/primitives/button";
|
||||
import * as React from 'react'
|
||||
import { Button } from './../../../components/ui/primitives/button'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "./../../../components/ui/primitives/tooltip";
|
||||
import { cn } from "./../../../lib/utils";
|
||||
} from './../../../components/ui/primitives/tooltip'
|
||||
import { cn } from './../../../lib/utils'
|
||||
|
||||
interface ActionButtonProps extends React.ComponentProps<typeof Button> {
|
||||
label: string;
|
||||
shortcut?: string;
|
||||
isActive?: boolean;
|
||||
tooltipContent?: React.ReactNode;
|
||||
tooltipSide?: "top" | "right" | "bottom" | "left";
|
||||
label: string
|
||||
shortcut?: string
|
||||
isActive?: boolean
|
||||
tooltipContent?: React.ReactNode
|
||||
tooltipSide?: 'top' | 'right' | 'bottom' | 'left'
|
||||
}
|
||||
|
||||
export const ActionButton = React.forwardRef<HTMLButtonElement, ActionButtonProps>(
|
||||
(
|
||||
{ className, children, label, shortcut, isActive, tooltipContent, tooltipSide, ...props },
|
||||
ref
|
||||
ref,
|
||||
) => {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
className={cn('relative h-11 w-11 transition-all', className)}
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative h-11 w-11 transition-all",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-full w-full items-center justify-center transition-transform",
|
||||
shortcut && "-translate-x-0.5 -translate-y-0.5"
|
||||
'flex h-full w-full items-center justify-center transition-transform',
|
||||
shortcut && '-translate-x-0.5 -translate-y-0.5',
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
{shortcut && (
|
||||
<div className="absolute bottom-1 right-1 rounded border border-border/40 bg-background/40 px-1 py-[2px] backdrop-blur-md">
|
||||
<span className="block font-mono text-[9px] font-medium leading-none text-muted-foreground/70">
|
||||
<div className="absolute right-1 bottom-1 rounded border border-border/40 bg-background/40 px-1 py-[2px] backdrop-blur-md">
|
||||
<span className="block font-medium font-mono text-[9px] text-muted-foreground/70 leading-none">
|
||||
{shortcut}
|
||||
</span>
|
||||
</div>
|
||||
@@ -56,7 +53,7 @@ export const ActionButton = React.forwardRef<HTMLButtonElement, ActionButtonProp
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
);
|
||||
ActionButton.displayName = "ActionButton";
|
||||
)
|
||||
},
|
||||
)
|
||||
ActionButton.displayName = 'ActionButton'
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { emitter } from '@pascal-app/core'
|
||||
import Image from 'next/image'
|
||||
import { ActionButton } from "./action-button";
|
||||
import { ActionButton } from './action-button'
|
||||
|
||||
export function CameraActions() {
|
||||
const goToTopView = () => {
|
||||
@@ -21,15 +21,15 @@ export function CameraActions() {
|
||||
<div className="flex items-center gap-1">
|
||||
{/* Orbit CCW */}
|
||||
<ActionButton
|
||||
label="Orbit Left"
|
||||
className="group hover:bg-white/5"
|
||||
label="Orbit Left"
|
||||
onClick={orbitCCW}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
<Image
|
||||
alt="Orbit Left"
|
||||
className="h-[28px] w-[28px] object-contain opacity-70 transition-opacity group-hover:opacity-100 -scale-x-100"
|
||||
className="h-[28px] w-[28px] -scale-x-100 object-contain opacity-70 transition-opacity group-hover:opacity-100"
|
||||
height={28}
|
||||
src="/icons/rotate.png"
|
||||
width={28}
|
||||
@@ -38,8 +38,8 @@ export function CameraActions() {
|
||||
|
||||
{/* Orbit CW */}
|
||||
<ActionButton
|
||||
label="Orbit Right"
|
||||
className="group hover:bg-white/5"
|
||||
label="Orbit Right"
|
||||
onClick={orbitCW}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
@@ -55,8 +55,8 @@ export function CameraActions() {
|
||||
|
||||
{/* Top View */}
|
||||
<ActionButton
|
||||
label="Top View"
|
||||
className="group hover:bg-white/5"
|
||||
label="Top View"
|
||||
onClick={goToTopView}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
|
||||
@@ -1,55 +1,54 @@
|
||||
"use client";
|
||||
'use client'
|
||||
|
||||
import Image from "next/image";
|
||||
import { ActionButton } from "./action-button";
|
||||
import { Pencil, Trash2, type LucideIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "./../../../lib/utils";
|
||||
import useEditor, { Mode, Phase } from "./../../../store/use-editor";
|
||||
import { type LucideIcon, Pencil, Trash2 } from 'lucide-react'
|
||||
import Image from 'next/image'
|
||||
import { cn } from './../../../lib/utils'
|
||||
import useEditor, { type Mode, type Phase } from './../../../store/use-editor'
|
||||
import { ActionButton } from './action-button'
|
||||
|
||||
type ModeConfig = {
|
||||
id: Mode;
|
||||
icon?: LucideIcon;
|
||||
imageSrc?: string;
|
||||
label: string;
|
||||
shortcut: string;
|
||||
color: string;
|
||||
activeColor: string;
|
||||
};
|
||||
id: Mode
|
||||
icon?: LucideIcon
|
||||
imageSrc?: string
|
||||
label: string
|
||||
shortcut: string
|
||||
color: string
|
||||
activeColor: string
|
||||
}
|
||||
|
||||
// All available control modes
|
||||
const allModes: ModeConfig[] = [
|
||||
{
|
||||
id: "select",
|
||||
imageSrc: "/icons/select.png",
|
||||
label: "Select",
|
||||
shortcut: "V",
|
||||
color: "hover:bg-blue-500/20 hover:text-blue-400",
|
||||
activeColor: "bg-blue-500/20 text-blue-400",
|
||||
id: 'select',
|
||||
imageSrc: '/icons/select.png',
|
||||
label: 'Select',
|
||||
shortcut: 'V',
|
||||
color: 'hover:bg-blue-500/20 hover:text-blue-400',
|
||||
activeColor: 'bg-blue-500/20 text-blue-400',
|
||||
},
|
||||
{
|
||||
id: "edit",
|
||||
id: 'edit',
|
||||
icon: Pencil,
|
||||
label: "Edit",
|
||||
shortcut: "E",
|
||||
color: "hover:bg-orange-500/20 hover:text-orange-400",
|
||||
activeColor: "bg-orange-500/20 text-orange-400",
|
||||
label: 'Edit',
|
||||
shortcut: 'E',
|
||||
color: 'hover:bg-orange-500/20 hover:text-orange-400',
|
||||
activeColor: 'bg-orange-500/20 text-orange-400',
|
||||
},
|
||||
{
|
||||
id: "build",
|
||||
imageSrc: "/icons/build.png",
|
||||
label: "Build",
|
||||
shortcut: "B",
|
||||
color: "hover:bg-green-500/20 hover:text-green-400",
|
||||
activeColor: "bg-green-500/20 text-green-400",
|
||||
id: 'build',
|
||||
imageSrc: '/icons/build.png',
|
||||
label: 'Build',
|
||||
shortcut: 'B',
|
||||
color: 'hover:bg-green-500/20 hover:text-green-400',
|
||||
activeColor: 'bg-green-500/20 text-green-400',
|
||||
},
|
||||
{
|
||||
id: "delete",
|
||||
id: 'delete',
|
||||
icon: Trash2,
|
||||
label: "Delete",
|
||||
shortcut: "D",
|
||||
color: "hover:bg-red-500/20 hover:text-red-400",
|
||||
activeColor: "bg-red-500/20 text-red-400",
|
||||
label: 'Delete',
|
||||
shortcut: 'D',
|
||||
color: 'hover:bg-red-500/20 hover:text-red-400',
|
||||
activeColor: 'bg-red-500/20 text-red-400',
|
||||
},
|
||||
// {
|
||||
// id: 'painting',
|
||||
@@ -67,49 +66,47 @@ const allModes: ModeConfig[] = [
|
||||
// color: 'hover:bg-purple-500/20 hover:text-purple-400',
|
||||
// activeColor: 'bg-purple-500/20 text-purple-400',
|
||||
// },
|
||||
];
|
||||
]
|
||||
|
||||
// Define which modes are available in each editor mode
|
||||
const modesByPhase: Record<Phase, Mode[]> = {
|
||||
site: ["select", "edit"],
|
||||
structure: ["select", "delete", "build"],
|
||||
furnish: ["select", "delete", "build"],
|
||||
};
|
||||
site: ['select', 'edit'],
|
||||
structure: ['select', 'delete', 'build'],
|
||||
furnish: ['select', 'delete', 'build'],
|
||||
}
|
||||
|
||||
export function ControlModes() {
|
||||
const mode = useEditor((state) => state.mode);
|
||||
const phase = useEditor((state) => state.phase);
|
||||
const setMode = useEditor((state) => state.setMode);
|
||||
const mode = useEditor((state) => state.mode)
|
||||
const phase = useEditor((state) => state.phase)
|
||||
const setMode = useEditor((state) => state.setMode)
|
||||
|
||||
const availableModeIds = modesByPhase[phase];
|
||||
const availableModes = allModes.filter((m) =>
|
||||
availableModeIds.includes(m.id)
|
||||
);
|
||||
const availableModeIds = modesByPhase[phase]
|
||||
const availableModes = allModes.filter((m) => availableModeIds.includes(m.id))
|
||||
|
||||
const handleModeClick = (mode: Mode) => {
|
||||
setMode(mode);
|
||||
};
|
||||
setMode(mode)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
{availableModes.map((m) => {
|
||||
const Icon = m.icon;
|
||||
const isActive = mode === m.id;
|
||||
const isImageMode = Boolean(m.imageSrc);
|
||||
const Icon = m.icon
|
||||
const isActive = mode === m.id
|
||||
const isImageMode = Boolean(m.imageSrc)
|
||||
|
||||
return (
|
||||
<ActionButton
|
||||
className={cn(
|
||||
'text-muted-foreground',
|
||||
!(isImageMode || isActive) && m.color,
|
||||
!isImageMode && isActive && m.activeColor,
|
||||
isImageMode && isActive && 'bg-white/10 hover:bg-white/10',
|
||||
isImageMode && !isActive && 'hover:bg-white/5',
|
||||
)}
|
||||
key={m.id}
|
||||
label={m.label}
|
||||
shortcut={m.shortcut}
|
||||
className={cn(
|
||||
"text-muted-foreground",
|
||||
!isImageMode && !isActive && m.color,
|
||||
!isImageMode && isActive && m.activeColor,
|
||||
isImageMode && isActive && "bg-white/10 hover:bg-white/10",
|
||||
isImageMode && !isActive && "hover:bg-white/5"
|
||||
)}
|
||||
onClick={() => handleModeClick(m.id)}
|
||||
shortcut={m.shortcut}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
@@ -117,9 +114,9 @@ export function ControlModes() {
|
||||
<Image
|
||||
alt={m.label}
|
||||
className={cn(
|
||||
"h-[28px] w-[28px] object-contain transition-[opacity,filter] duration-200",
|
||||
!isActive && "opacity-60 grayscale",
|
||||
isActive && "opacity-100 grayscale-0"
|
||||
'h-[28px] w-[28px] object-contain transition-[opacity,filter] duration-200',
|
||||
!isActive && 'opacity-60 grayscale',
|
||||
isActive && 'opacity-100 grayscale-0',
|
||||
)}
|
||||
height={28}
|
||||
src={m.imageSrc}
|
||||
@@ -129,8 +126,8 @@ export function ControlModes() {
|
||||
Icon && <Icon className="h-5 w-5" />
|
||||
)}
|
||||
</ActionButton>
|
||||
);
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,89 +1,86 @@
|
||||
"use client";
|
||||
'use client'
|
||||
|
||||
import NextImage from "next/image";
|
||||
import { ActionButton } from "./action-button";
|
||||
|
||||
import { cn } from "./../../../lib/utils";
|
||||
import useEditor, { CatalogCategory } from "./../../../store/use-editor";
|
||||
import NextImage from 'next/image'
|
||||
import { cn } from './../../../lib/utils'
|
||||
import useEditor, { type CatalogCategory } from './../../../store/use-editor'
|
||||
import { ActionButton } from './action-button'
|
||||
|
||||
export type FurnishToolConfig = {
|
||||
id: "item";
|
||||
iconSrc: string;
|
||||
label: string;
|
||||
catalogCategory: CatalogCategory;
|
||||
};
|
||||
id: 'item'
|
||||
iconSrc: string
|
||||
label: string
|
||||
catalogCategory: CatalogCategory
|
||||
}
|
||||
|
||||
// Furnish mode tools: furniture, appliances, decoration (painting is now a control mode)
|
||||
export const furnishTools: FurnishToolConfig[] = [
|
||||
{
|
||||
id: "item",
|
||||
iconSrc: "/icons/couch.png",
|
||||
label: "Furniture",
|
||||
catalogCategory: "furniture",
|
||||
id: 'item',
|
||||
iconSrc: '/icons/couch.png',
|
||||
label: 'Furniture',
|
||||
catalogCategory: 'furniture',
|
||||
},
|
||||
{
|
||||
id: "item",
|
||||
iconSrc: "/icons/appliance.png",
|
||||
label: "Appliance",
|
||||
catalogCategory: "appliance",
|
||||
id: 'item',
|
||||
iconSrc: '/icons/appliance.png',
|
||||
label: 'Appliance',
|
||||
catalogCategory: 'appliance',
|
||||
},
|
||||
{
|
||||
id: "item",
|
||||
iconSrc: "/icons/kitchen.png",
|
||||
label: "Kitchen",
|
||||
catalogCategory: "kitchen",
|
||||
id: 'item',
|
||||
iconSrc: '/icons/kitchen.png',
|
||||
label: 'Kitchen',
|
||||
catalogCategory: 'kitchen',
|
||||
},
|
||||
{
|
||||
id: "item",
|
||||
iconSrc: "/icons/bathroom.png",
|
||||
label: "Bathroom",
|
||||
catalogCategory: "bathroom",
|
||||
id: 'item',
|
||||
iconSrc: '/icons/bathroom.png',
|
||||
label: 'Bathroom',
|
||||
catalogCategory: 'bathroom',
|
||||
},
|
||||
{
|
||||
id: "item",
|
||||
iconSrc: "/icons/tree.png",
|
||||
label: "Outdoor",
|
||||
catalogCategory: "outdoor",
|
||||
id: 'item',
|
||||
iconSrc: '/icons/tree.png',
|
||||
label: 'Outdoor',
|
||||
catalogCategory: 'outdoor',
|
||||
},
|
||||
];
|
||||
]
|
||||
|
||||
export function FurnishTools() {
|
||||
const mode = useEditor((state) => state.mode);
|
||||
const activeTool = useEditor((state) => state.tool);
|
||||
const setActiveTool = useEditor((state) => state.setTool);
|
||||
const setMode = useEditor((state) => state.setMode);
|
||||
const catalogCategory = useEditor((state) => state.catalogCategory);
|
||||
const setCatalogCategory = useEditor((state) => state.setCatalogCategory);
|
||||
const mode = useEditor((state) => state.mode)
|
||||
const activeTool = useEditor((state) => state.tool)
|
||||
const setActiveTool = useEditor((state) => state.setTool)
|
||||
const setMode = useEditor((state) => state.setMode)
|
||||
const catalogCategory = useEditor((state) => state.catalogCategory)
|
||||
const setCatalogCategory = useEditor((state) => state.setCatalogCategory)
|
||||
|
||||
const hasActiveTool = furnishTools.some((tool) =>
|
||||
mode === "build" &&
|
||||
activeTool === "item" &&
|
||||
catalogCategory === tool.catalogCategory
|
||||
);
|
||||
const hasActiveTool = furnishTools.some(
|
||||
(tool) => mode === 'build' && activeTool === 'item' && catalogCategory === tool.catalogCategory,
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 px-1">
|
||||
{furnishTools.map((tool, index) => {
|
||||
// For item tools with catalog category, check both tool and category match
|
||||
const isActive =
|
||||
mode === "build" &&
|
||||
activeTool === "item" &&
|
||||
catalogCategory === tool.catalogCategory;
|
||||
mode === 'build' && activeTool === 'item' && catalogCategory === tool.catalogCategory
|
||||
|
||||
return (
|
||||
<ActionButton
|
||||
className={cn(
|
||||
'rounded-lg duration-300',
|
||||
isActive
|
||||
? 'z-10 scale-110 bg-black/40 hover:bg-black/40'
|
||||
: 'scale-95 bg-transparent opacity-60 grayscale hover:bg-black/20 hover:opacity-100 hover:grayscale-0',
|
||||
)}
|
||||
key={`${tool.id}-${tool.catalogCategory ?? index}`}
|
||||
label={tool.label}
|
||||
className={cn(
|
||||
"rounded-lg duration-300",
|
||||
isActive ? "bg-black/40 hover:bg-black/40 scale-110 z-10" : "bg-transparent opacity-60 grayscale hover:opacity-100 hover:grayscale-0 hover:bg-black/20 scale-95",
|
||||
)}
|
||||
onClick={() => {
|
||||
if (!isActive) {
|
||||
setCatalogCategory(tool.catalogCategory);
|
||||
setActiveTool("item");
|
||||
if (mode !== "build") {
|
||||
setMode("build");
|
||||
setCatalogCategory(tool.catalogCategory)
|
||||
setActiveTool('item')
|
||||
if (mode !== 'build') {
|
||||
setMode('build')
|
||||
}
|
||||
}
|
||||
}}
|
||||
@@ -98,8 +95,8 @@ export function FurnishTools() {
|
||||
width={28}
|
||||
/>
|
||||
</ActionButton>
|
||||
);
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,54 +1,43 @@
|
||||
"use client";
|
||||
'use client'
|
||||
|
||||
import { TooltipProvider } from "./../../../components/ui/primitives/tooltip";
|
||||
import { cn } from "./../../../lib/utils";
|
||||
|
||||
import { CameraActions } from "./camera-actions";
|
||||
import { ControlModes } from "./control-modes";
|
||||
import { StructureTools } from "./structure-tools";
|
||||
import useEditor from "./../../../store/use-editor";
|
||||
import { useReducedMotion } from "./../../../hooks/use-reduced-motion";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { ItemCatalog } from "../item-catalog/item-catalog";
|
||||
import { FurnishTools } from "./furnish-tools";
|
||||
import { ViewToggles } from "./view-toggles";
|
||||
import { AnimatePresence, motion } from 'motion/react'
|
||||
import { TooltipProvider } from './../../../components/ui/primitives/tooltip'
|
||||
import { useReducedMotion } from './../../../hooks/use-reduced-motion'
|
||||
import { cn } from './../../../lib/utils'
|
||||
import useEditor from './../../../store/use-editor'
|
||||
import { ItemCatalog } from '../item-catalog/item-catalog'
|
||||
import { CameraActions } from './camera-actions'
|
||||
import { ControlModes } from './control-modes'
|
||||
import { FurnishTools } from './furnish-tools'
|
||||
import { StructureTools } from './structure-tools'
|
||||
import { ViewToggles } from './view-toggles'
|
||||
|
||||
export function ActionMenu({ className }: { className?: string }) {
|
||||
const phase = useEditor((state) => state.phase);
|
||||
const mode = useEditor((state) => state.mode);
|
||||
const tool = useEditor((state) => state.tool);
|
||||
const catalogCategory = useEditor((state) => state.catalogCategory);
|
||||
const reducedMotion = useReducedMotion();
|
||||
const phase = useEditor((state) => state.phase)
|
||||
const mode = useEditor((state) => state.mode)
|
||||
const tool = useEditor((state) => state.tool)
|
||||
const catalogCategory = useEditor((state) => state.catalogCategory)
|
||||
const reducedMotion = useReducedMotion()
|
||||
const transition = reducedMotion
|
||||
? { duration: 0 }
|
||||
: { type: "spring" as const, bounce: 0.2, duration: 0.4 };
|
||||
: { type: 'spring' as const, bounce: 0.2, duration: 0.4 }
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<motion.div
|
||||
layout
|
||||
transition={transition}
|
||||
className={cn(
|
||||
"-translate-x-1/2 fixed bottom-6 left-1/2 z-50",
|
||||
"rounded-2xl border border-border bg-background/90 shadow-2xl backdrop-blur-md",
|
||||
"transition-colors duration-200 ease-out",
|
||||
'fixed bottom-6 left-1/2 z-50 -translate-x-1/2',
|
||||
'rounded-2xl border border-border bg-background/90 shadow-2xl backdrop-blur-md',
|
||||
'transition-colors duration-200 ease-out',
|
||||
className,
|
||||
)}
|
||||
layout
|
||||
transition={transition}
|
||||
>
|
||||
{/* Item Catalog Row - Only show when in build mode with item tool */}
|
||||
<AnimatePresence>
|
||||
{mode === "build" && tool === "item" && catalogCategory && (
|
||||
{mode === 'build' && tool === 'item' && catalogCategory && (
|
||||
<motion.div
|
||||
className={cn(
|
||||
"overflow-hidden border-border border-b px-2 py-2",
|
||||
)}
|
||||
initial={{
|
||||
opacity: 0,
|
||||
maxHeight: 0,
|
||||
paddingTop: 0,
|
||||
paddingBottom: 0,
|
||||
borderBottomWidth: 0,
|
||||
}}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
maxHeight: 160,
|
||||
@@ -56,6 +45,7 @@ export function ActionMenu({ className }: { className?: string }) {
|
||||
paddingBottom: 8,
|
||||
borderBottomWidth: 1,
|
||||
}}
|
||||
className={cn('overflow-hidden border-border border-b px-2 py-2')}
|
||||
exit={{
|
||||
opacity: 0,
|
||||
maxHeight: 0,
|
||||
@@ -63,27 +53,23 @@ export function ActionMenu({ className }: { className?: string }) {
|
||||
paddingBottom: 0,
|
||||
borderBottomWidth: 0,
|
||||
}}
|
||||
initial={{
|
||||
opacity: 0,
|
||||
maxHeight: 0,
|
||||
paddingTop: 0,
|
||||
paddingBottom: 0,
|
||||
borderBottomWidth: 0,
|
||||
}}
|
||||
transition={transition}
|
||||
>
|
||||
<ItemCatalog key={catalogCategory} category={catalogCategory} />
|
||||
<ItemCatalog category={catalogCategory} key={catalogCategory} />
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<AnimatePresence>
|
||||
{phase === "furnish" && mode === "build" && (
|
||||
{phase === 'furnish' && mode === 'build' && (
|
||||
<motion.div
|
||||
className={cn(
|
||||
"overflow-hidden border-border",
|
||||
"max-h-20 border-b px-2 py-2 opacity-100",
|
||||
)}
|
||||
initial={{
|
||||
opacity: 0,
|
||||
maxHeight: 0,
|
||||
paddingTop: 0,
|
||||
paddingBottom: 0,
|
||||
borderBottomWidth: 0,
|
||||
}}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
maxHeight: 80,
|
||||
@@ -91,6 +77,10 @@ export function ActionMenu({ className }: { className?: string }) {
|
||||
paddingBottom: 8,
|
||||
borderBottomWidth: 1,
|
||||
}}
|
||||
className={cn(
|
||||
'overflow-hidden border-border',
|
||||
'max-h-20 border-b px-2 py-2 opacity-100',
|
||||
)}
|
||||
exit={{
|
||||
opacity: 0,
|
||||
maxHeight: 0,
|
||||
@@ -98,6 +88,13 @@ export function ActionMenu({ className }: { className?: string }) {
|
||||
paddingBottom: 0,
|
||||
borderBottomWidth: 0,
|
||||
}}
|
||||
initial={{
|
||||
opacity: 0,
|
||||
maxHeight: 0,
|
||||
paddingTop: 0,
|
||||
paddingBottom: 0,
|
||||
borderBottomWidth: 0,
|
||||
}}
|
||||
transition={transition}
|
||||
>
|
||||
<div className="mx-auto w-max">
|
||||
@@ -109,18 +106,8 @@ export function ActionMenu({ className }: { className?: string }) {
|
||||
|
||||
{/* Structure Tools Row - Animated */}
|
||||
<AnimatePresence>
|
||||
{phase === "structure" && mode === "build" && (
|
||||
{phase === 'structure' && mode === 'build' && (
|
||||
<motion.div
|
||||
className={cn(
|
||||
"overflow-hidden border-border max-h-20 border-b px-2 py-2",
|
||||
)}
|
||||
initial={{
|
||||
opacity: 0,
|
||||
maxHeight: 0,
|
||||
paddingTop: 0,
|
||||
paddingBottom: 0,
|
||||
borderBottomWidth: 0,
|
||||
}}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
maxHeight: 80,
|
||||
@@ -128,6 +115,7 @@ export function ActionMenu({ className }: { className?: string }) {
|
||||
paddingBottom: 8,
|
||||
borderBottomWidth: 1,
|
||||
}}
|
||||
className={cn('max-h-20 overflow-hidden border-border border-b px-2 py-2')}
|
||||
exit={{
|
||||
opacity: 0,
|
||||
maxHeight: 0,
|
||||
@@ -135,6 +123,13 @@ export function ActionMenu({ className }: { className?: string }) {
|
||||
paddingBottom: 0,
|
||||
borderBottomWidth: 0,
|
||||
}}
|
||||
initial={{
|
||||
opacity: 0,
|
||||
maxHeight: 0,
|
||||
paddingTop: 0,
|
||||
paddingBottom: 0,
|
||||
borderBottomWidth: 0,
|
||||
}}
|
||||
transition={transition}
|
||||
>
|
||||
<div className="w-max">
|
||||
@@ -153,5 +148,5 @@ export function ActionMenu({ className }: { className?: string }) {
|
||||
</div>
|
||||
</motion.div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,14 +1,22 @@
|
||||
'use client'
|
||||
|
||||
import NextImage from 'next/image'
|
||||
import { ActionButton } from "./action-button";
|
||||
|
||||
import { cn } from '../../../lib/utils'
|
||||
import useEditor, { CatalogCategory, StructureTool, Tool } from '../../../store/use-editor'
|
||||
import { useContextualTools } from '../../../hooks/use-contextual-tools'
|
||||
|
||||
import { cn } from '../../../lib/utils'
|
||||
import useEditor, {
|
||||
type CatalogCategory,
|
||||
type StructureTool,
|
||||
Tool,
|
||||
} from '../../../store/use-editor'
|
||||
import { ActionButton } from './action-button'
|
||||
|
||||
export type ToolConfig = {
|
||||
id: StructureTool; iconSrc: string; label: string; catalogCategory?: CatalogCategory }
|
||||
id: StructureTool
|
||||
iconSrc: string
|
||||
label: string
|
||||
catalogCategory?: CatalogCategory
|
||||
}
|
||||
|
||||
export const tools: ToolConfig[] = [
|
||||
{ id: 'wall', iconSrc: '/icons/wall.png', label: 'Wall' },
|
||||
@@ -26,19 +34,20 @@ export function StructureTools() {
|
||||
const activeTool = useEditor((state) => state.tool)
|
||||
const catalogCategory = useEditor((state) => state.catalogCategory)
|
||||
const structureLayer = useEditor((state) => state.structureLayer)
|
||||
const setTool = useEditor((state) => state.setTool)
|
||||
const setTool = useEditor((state) => state.setTool)
|
||||
const setCatalogCategory = useEditor((state) => state.setCatalogCategory)
|
||||
|
||||
|
||||
const contextualTools = useContextualTools()
|
||||
|
||||
// Filter tools based on structureLayer
|
||||
const visibleTools = structureLayer === 'zones'
|
||||
? tools.filter((t) => t.id === 'zone')
|
||||
: tools.filter((t) => t.id !== 'zone')
|
||||
const visibleTools =
|
||||
structureLayer === 'zones'
|
||||
? tools.filter((t) => t.id === 'zone')
|
||||
: tools.filter((t) => t.id !== 'zone')
|
||||
|
||||
const hasActiveTool = visibleTools.some((t) =>
|
||||
activeTool === t.id &&
|
||||
(t.catalogCategory ? catalogCategory === t.catalogCategory : true)
|
||||
const hasActiveTool = visibleTools.some(
|
||||
(t) =>
|
||||
activeTool === t.id && (t.catalogCategory ? catalogCategory === t.catalogCategory : true),
|
||||
)
|
||||
|
||||
return (
|
||||
@@ -48,22 +57,24 @@ export function StructureTools() {
|
||||
const isActive =
|
||||
activeTool === tool.id &&
|
||||
(tool.catalogCategory ? catalogCategory === tool.catalogCategory : true)
|
||||
|
||||
|
||||
const isContextual = contextualTools.includes(tool.id)
|
||||
|
||||
return (
|
||||
<ActionButton
|
||||
key={`${tool.id}-${tool.catalogCategory ?? index}`}
|
||||
label={tool.label}
|
||||
className={cn(
|
||||
'rounded-lg duration-300',
|
||||
isActive ? 'bg-black/40 hover:bg-black/40 scale-110 z-10' : 'bg-transparent opacity-60 grayscale hover:opacity-100 hover:grayscale-0 hover:bg-black/20 scale-95',
|
||||
isActive
|
||||
? 'z-10 scale-110 bg-black/40 hover:bg-black/40'
|
||||
: 'scale-95 bg-transparent opacity-60 grayscale hover:bg-black/20 hover:opacity-100 hover:grayscale-0',
|
||||
)}
|
||||
key={`${tool.id}-${tool.catalogCategory ?? index}`}
|
||||
label={tool.label}
|
||||
onClick={() => {
|
||||
if (!isActive) {
|
||||
setTool(tool.id)
|
||||
setCatalogCategory(tool.catalogCategory ?? null)
|
||||
|
||||
|
||||
// Automatically switch to build mode if we select a tool
|
||||
if (useEditor.getState().mode !== 'build') {
|
||||
useEditor.getState().setMode('build')
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { Box, Camera, Diamond, Image, Layers, Layers2 } from 'lucide-react'
|
||||
import { ActionButton } from "./action-button";
|
||||
import { cn } from '../../../lib/utils'
|
||||
import { ActionButton } from './action-button'
|
||||
|
||||
const levelModeLabels: Record<'stacked' | 'exploded' | 'solo', string> = {
|
||||
stacked: 'Stacked',
|
||||
@@ -77,12 +77,12 @@ export function ViewToggles() {
|
||||
<div className="flex items-center gap-1">
|
||||
{/* Camera Mode */}
|
||||
<ActionButton
|
||||
label={`Camera: ${cameraMode === 'perspective' ? 'Perspective' : 'Orthographic'}`}
|
||||
className={cn(
|
||||
cameraMode === 'orthographic'
|
||||
? 'bg-violet-500/20 text-violet-400'
|
||||
: 'hover:text-violet-400',
|
||||
)}
|
||||
label={`Camera: ${cameraMode === 'perspective' ? 'Perspective' : 'Orthographic'}`}
|
||||
onClick={toggleCameraMode}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
@@ -92,12 +92,10 @@ export function ViewToggles() {
|
||||
|
||||
{/* Level Mode */}
|
||||
<ActionButton
|
||||
label={`Levels: ${levelMode === 'manual' ? 'Manual' : levelModeLabels[levelMode as keyof typeof levelModeLabels]}`}
|
||||
className={cn(
|
||||
levelMode !== 'stacked'
|
||||
? 'bg-amber-500/20 text-amber-400'
|
||||
: 'hover:text-amber-400',
|
||||
levelMode !== 'stacked' ? 'bg-amber-500/20 text-amber-400' : 'hover:text-amber-400',
|
||||
)}
|
||||
label={`Levels: ${levelMode === 'manual' ? 'Manual' : levelModeLabels[levelMode as keyof typeof levelModeLabels]}`}
|
||||
onClick={cycleLevelMode}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
@@ -109,13 +107,13 @@ export function ViewToggles() {
|
||||
|
||||
{/* Wall Mode */}
|
||||
<ActionButton
|
||||
label={`Walls: ${wallModeConfig[wallMode].label}`}
|
||||
className={cn(
|
||||
'p-0',
|
||||
wallMode !== 'cutaway'
|
||||
? 'bg-white/10'
|
||||
: 'opacity-60 grayscale hover:opacity-100 hover:grayscale-0 hover:bg-white/5',
|
||||
: 'opacity-60 grayscale hover:bg-white/5 hover:opacity-100 hover:grayscale-0',
|
||||
)}
|
||||
label={`Walls: ${wallModeConfig[wallMode].label}`}
|
||||
onClick={cycleWallMode}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
@@ -128,13 +126,13 @@ export function ViewToggles() {
|
||||
|
||||
{/* Show Scans */}
|
||||
<ActionButton
|
||||
label={`Scans: ${showScans ? 'Visible' : 'Hidden'}`}
|
||||
className={cn(
|
||||
'p-0',
|
||||
showScans
|
||||
? 'bg-white/10'
|
||||
: 'opacity-60 grayscale hover:opacity-100 hover:grayscale-0 hover:bg-white/5',
|
||||
: 'opacity-60 grayscale hover:bg-white/5 hover:opacity-100 hover:grayscale-0',
|
||||
)}
|
||||
label={`Scans: ${showScans ? 'Visible' : 'Hidden'}`}
|
||||
onClick={() => setShowScans(!showScans)}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
@@ -144,13 +142,13 @@ export function ViewToggles() {
|
||||
|
||||
{/* Show Guides */}
|
||||
<ActionButton
|
||||
label={`Guides: ${showGuides ? 'Visible' : 'Hidden'}`}
|
||||
className={cn(
|
||||
'p-0',
|
||||
showGuides
|
||||
? 'bg-white/10'
|
||||
: 'opacity-60 grayscale hover:opacity-100 hover:grayscale-0 hover:bg-white/5',
|
||||
: 'opacity-60 grayscale hover:bg-white/5 hover:opacity-100 hover:grayscale-0',
|
||||
)}
|
||||
label={`Guides: ${showGuides ? 'Visible' : 'Hidden'}`}
|
||||
onClick={() => setShowGuides(!showGuides)}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -12,8 +12,8 @@ export function ActionButton({ icon, label, className, ...props }: ActionButtonP
|
||||
<button
|
||||
{...props}
|
||||
className={cn(
|
||||
"flex h-9 flex-1 items-center justify-center gap-1.5 rounded-lg border border-border/50 bg-[#2C2C2E] px-3 text-xs font-medium text-foreground transition-colors hover:bg-[#3e3e3e] active:bg-[#3e3e3e]",
|
||||
className
|
||||
'flex h-9 flex-1 items-center justify-center gap-1.5 rounded-lg border border-border/50 bg-[#2C2C2E] px-3 font-medium text-foreground text-xs transition-colors hover:bg-[#3e3e3e] active:bg-[#3e3e3e]',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
@@ -22,10 +22,12 @@ export function ActionButton({ icon, label, className, ...props }: ActionButtonP
|
||||
)
|
||||
}
|
||||
|
||||
export function ActionGroup({ children, className }: { children: React.ReactNode; className?: string }) {
|
||||
return (
|
||||
<div className={cn("flex gap-1.5", className)}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
export function ActionGroup({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}) {
|
||||
return <div className={cn('flex gap-1.5', className)}>{children}</div>
|
||||
}
|
||||
|
||||
@@ -20,8 +20,8 @@ export function MetricControl({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
min = -Infinity,
|
||||
max = Infinity,
|
||||
min = Number.NEGATIVE_INFINITY,
|
||||
max = Number.POSITIVE_INFINITY,
|
||||
precision = 2,
|
||||
step = 1,
|
||||
className,
|
||||
@@ -57,9 +57,9 @@ export function MetricControl({
|
||||
|
||||
const handleWheel = (e: WheelEvent) => {
|
||||
if (isEditing) return
|
||||
|
||||
|
||||
e.preventDefault()
|
||||
|
||||
|
||||
const direction = e.deltaY < 0 ? 1 : -1
|
||||
let scrollStep = step
|
||||
if (e.shiftKey) scrollStep = step * 10
|
||||
@@ -67,12 +67,12 @@ export function MetricControl({
|
||||
|
||||
const newValue = clamp(valueRef.current + direction * scrollStep)
|
||||
const finalValue = Number.parseFloat(newValue.toFixed(precision))
|
||||
|
||||
|
||||
if (finalValue !== valueRef.current) {
|
||||
onChange(finalValue)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
container.addEventListener('wheel', handleWheel, { passive: false })
|
||||
return () => container.removeEventListener('wheel', handleWheel)
|
||||
}, [isEditing, step, clamp, onChange, precision])
|
||||
@@ -84,16 +84,16 @@ export function MetricControl({
|
||||
let direction = 0
|
||||
if (e.key === 'ArrowUp') direction = 1
|
||||
else if (e.key === 'ArrowDown') direction = -1
|
||||
|
||||
|
||||
if (direction !== 0) {
|
||||
e.preventDefault()
|
||||
let scrollStep = step
|
||||
if (e.shiftKey) scrollStep = step * 10
|
||||
else if (e.altKey) scrollStep = step * 0.1
|
||||
|
||||
|
||||
const newValue = clamp(valueRef.current + direction * scrollStep)
|
||||
const finalValue = Number.parseFloat(newValue.toFixed(precision))
|
||||
|
||||
|
||||
if (finalValue !== valueRef.current) {
|
||||
onChange(finalValue)
|
||||
}
|
||||
@@ -108,17 +108,17 @@ export function MetricControl({
|
||||
(e: React.PointerEvent) => {
|
||||
if (isEditing) return
|
||||
e.preventDefault()
|
||||
|
||||
|
||||
setIsDragging(true)
|
||||
startXRef.current = e.clientX
|
||||
startValueRef.current = value
|
||||
useScene.temporal.getState().pause()
|
||||
|
||||
|
||||
let finalValue = value
|
||||
|
||||
const handlePointerMove = (moveEvent: PointerEvent) => {
|
||||
const deltaX = moveEvent.clientX - startXRef.current
|
||||
|
||||
|
||||
let dragStep = step
|
||||
if (moveEvent.shiftKey) dragStep = step * 10
|
||||
else if (moveEvent.altKey) dragStep = step * 0.1
|
||||
@@ -137,7 +137,7 @@ export function MetricControl({
|
||||
setIsDragging(false)
|
||||
document.removeEventListener('pointermove', handlePointerMove)
|
||||
document.removeEventListener('pointerup', handlePointerUp)
|
||||
|
||||
|
||||
if (finalValue !== startValueRef.current) {
|
||||
onChange(startValueRef.current)
|
||||
useScene.temporal.getState().resume()
|
||||
@@ -150,7 +150,7 @@ export function MetricControl({
|
||||
document.addEventListener('pointermove', handlePointerMove)
|
||||
document.addEventListener('pointerup', handlePointerUp)
|
||||
},
|
||||
[isEditing, value, onChange, clamp, precision, step]
|
||||
[isEditing, value, onChange, clamp, precision, step],
|
||||
)
|
||||
|
||||
const handleValueClick = useCallback(() => {
|
||||
@@ -164,10 +164,10 @@ export function MetricControl({
|
||||
|
||||
const submitValue = useCallback(() => {
|
||||
const numValue = Number.parseFloat(inputValue)
|
||||
if (!Number.isNaN(numValue)) {
|
||||
onChange(clamp(Number.parseFloat(numValue.toFixed(precision))))
|
||||
} else {
|
||||
if (Number.isNaN(numValue)) {
|
||||
setInputValue(value.toFixed(precision))
|
||||
} else {
|
||||
onChange(clamp(Number.parseFloat(numValue.toFixed(precision))))
|
||||
}
|
||||
setIsEditing(false)
|
||||
}, [inputValue, onChange, clamp, precision, value])
|
||||
@@ -199,28 +199,34 @@ export function MetricControl({
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
<div
|
||||
className={cn(
|
||||
'group flex h-10 w-full items-center justify-between rounded-lg border border-border/50 px-3 text-sm transition-colors',
|
||||
isDragging ? 'bg-[#3e3e3e]' : 'bg-[#2C2C2E] hover:bg-[#3e3e3e]',
|
||||
className,
|
||||
)}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
className={cn("group flex h-10 w-full items-center justify-between rounded-lg border border-border/50 px-3 text-sm transition-colors", isDragging ? "bg-[#3e3e3e]" : "bg-[#2C2C2E] hover:bg-[#3e3e3e]", className)}
|
||||
ref={containerRef}
|
||||
>
|
||||
<div
|
||||
<div
|
||||
className={cn(
|
||||
"text-muted-foreground select-none truncate transition-colors",
|
||||
isDragging ? "cursor-ew-resize text-foreground" : "hover:text-foreground hover:cursor-ew-resize"
|
||||
'select-none truncate text-muted-foreground transition-colors',
|
||||
isDragging
|
||||
? 'cursor-ew-resize text-foreground'
|
||||
: 'hover:cursor-ew-resize hover:text-foreground',
|
||||
)}
|
||||
onPointerDown={handlePointerDown}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex shrink-0 justify-end">
|
||||
{isEditing ? (
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
autoFocus
|
||||
className="w-full bg-transparent p-0 text-right text-foreground font-mono outline-none selection:bg-primary/30"
|
||||
className="w-full bg-transparent p-0 text-right font-mono text-foreground outline-none selection:bg-primary/30"
|
||||
onBlur={handleInputBlur}
|
||||
onChange={handleInputChange}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
@@ -231,7 +237,7 @@ export function MetricControl({
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="flex w-full cursor-text items-center justify-end text-foreground hover:text-primary transition-colors"
|
||||
className="flex w-full cursor-text items-center justify-end text-foreground transition-colors hover:text-primary"
|
||||
onClick={handleValueClick}
|
||||
>
|
||||
<span className="font-mono tabular-nums tracking-tight">
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
'use client'
|
||||
|
||||
import { cn } from '../../../lib/utils'
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
import { AnimatePresence, motion } from 'motion/react'
|
||||
import { useState } from 'react'
|
||||
import { motion, AnimatePresence } from 'motion/react'
|
||||
import { cn } from '../../../lib/utils'
|
||||
|
||||
interface PanelSectionProps {
|
||||
title: string
|
||||
@@ -21,44 +21,42 @@ export function PanelSection({
|
||||
const [isExpanded, setIsExpanded] = useState(defaultExpanded)
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
<motion.div
|
||||
className={cn('flex shrink-0 flex-col overflow-hidden border-border/50 border-b', className)}
|
||||
layout
|
||||
transition={{ type: "spring", bounce: 0, duration: 0.4 }}
|
||||
className={cn("flex flex-col shrink-0 overflow-hidden border-b border-border/50", className)}
|
||||
transition={{ type: 'spring', bounce: 0, duration: 0.4 }}
|
||||
>
|
||||
<motion.button
|
||||
layout="position"
|
||||
type="button"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className={cn(
|
||||
"group/section flex items-center justify-between h-10 px-3 transition-all duration-200 shrink-0",
|
||||
'group/section flex h-10 shrink-0 items-center justify-between px-3 transition-all duration-200',
|
||||
isExpanded
|
||||
? "bg-accent/50 text-foreground"
|
||||
: "text-muted-foreground hover:bg-accent/30 hover:text-foreground"
|
||||
? 'bg-accent/50 text-foreground'
|
||||
: 'text-muted-foreground hover:bg-accent/30 hover:text-foreground',
|
||||
)}
|
||||
layout="position"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
type="button"
|
||||
>
|
||||
<span className="font-medium text-sm truncate">{title}</span>
|
||||
<ChevronDown
|
||||
<span className="truncate font-medium text-sm">{title}</span>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"h-4 w-4 transition-transform duration-200",
|
||||
isExpanded ? "rotate-180" : "rotate-0",
|
||||
isExpanded ? "text-foreground" : "opacity-0 group-hover/section:opacity-100"
|
||||
)}
|
||||
'h-4 w-4 transition-transform duration-200',
|
||||
isExpanded ? 'rotate-180' : 'rotate-0',
|
||||
isExpanded ? 'text-foreground' : 'opacity-0 group-hover/section:opacity-100',
|
||||
)}
|
||||
/>
|
||||
</motion.button>
|
||||
|
||||
|
||||
<AnimatePresence initial={false}>
|
||||
{isExpanded && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: "auto", opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ type: "spring", bounce: 0, duration: 0.4 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
className="overflow-hidden"
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
transition={{ type: 'spring', bounce: 0, duration: 0.4 }}
|
||||
>
|
||||
<div className="flex flex-col gap-1.5 p-3 pt-2">
|
||||
{children}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5 p-3 pt-2">{children}</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user