remove community

This commit is contained in:
wass08
2026-03-13 09:07:46 +01:00
parent 3eeba322a8
commit f2501be2bf
693 changed files with 8264 additions and 35562 deletions
-142
View File
@@ -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
-24
View File
@@ -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"
}
}
-61
View File
@@ -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']>
-99
View File
@@ -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>
-9
View File
@@ -1,9 +0,0 @@
{
"extends": "@repo/typescript-config/base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}
-127
View File
@@ -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
-22
View File
@@ -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,
})
-33
View File
@@ -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"
}
}
-11
View File
@@ -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
-56
View File
@@ -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})`
-24
View File
@@ -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)
-19
View File
@@ -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)
-160
View File
@@ -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: {}
}
}
-11
View File
@@ -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"]
}
+1 -1
View File
@@ -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"
+13 -1
View File
@@ -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
+5 -1
View File
@@ -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'
+1 -1
View File
@@ -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[]
+22 -12
View File
@@ -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'
+17 -4
View File
@@ -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
+1 -4
View File
@@ -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 ---
+13 -1
View File
@@ -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 -1
View File
@@ -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) => {
+12 -7
View File
@@ -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({
+85 -19
View File
@@ -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 ──
+1 -1
View File
@@ -4,4 +4,4 @@
*/
export const isObject = (val: unknown): val is Record<string, any> => {
return val !== null && typeof val === 'object' && !Array.isArray(val)
}
}
+2 -1
View File
@@ -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
},
+3 -5
View File
@@ -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>
+19 -13
View File
@@ -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>
)
+54 -47
View File
@@ -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>
+33 -33
View File
@@ -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