splitting editor and community

This commit is contained in:
wass08
2026-03-11 12:16:26 +01:00
parent 5108636d49
commit 7359c1fcbf
586 changed files with 6477 additions and 1546 deletions
@@ -1,11 +0,0 @@
/**
* Better Auth API route handler
* Handles all /api/auth/* routes for authentication
*/
import { toNextJsHandler } from 'better-auth/next-js'
import { auth } from '@/lib/auth'
const { GET, POST } = toNextJsHandler(auth)
export { GET, POST }
-89
View File
@@ -1,89 +0,0 @@
import { NextRequest, NextResponse } from 'next/server'
import { headers } from 'next/headers'
import { auth } from '@/lib/auth'
import { supabaseAdmin } from '@/lib/supabase/server'
// PUT /api/presets/[id]
// Accepts any subset of: name, data, is_community
export async function PUT(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
const session = await auth.api.getSession({ headers: await headers() })
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id } = await params
const body = await req.json()
const { name, data, is_community } = body
if (name === undefined && data === undefined && is_community === undefined) {
return NextResponse.json({ error: 'Nothing to update' }, { status: 400 })
}
const existingResult = await supabaseAdmin
.from('presets')
.select('user_id')
.eq('id', id)
.single()
const existing = existingResult.data as { user_id: string | null } | null
if (!existing || existing.user_id !== session.user.id) {
return NextResponse.json({ error: 'Not found or forbidden' }, { status: 403 })
}
const updates: Record<string, unknown> = {}
if (name !== undefined) updates.name = name
if (data !== undefined) updates.data = data
if (is_community !== undefined) updates.is_community = is_community
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { data: preset, error } = await (supabaseAdmin as any)
.from('presets')
.update(updates)
.eq('id', id)
.select()
.single()
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json({ preset })
}
// DELETE /api/presets/[id]
export async function DELETE(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
const session = await auth.api.getSession({ headers: await headers() })
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id } = await params
const existingResult = await supabaseAdmin
.from('presets')
.select('user_id, thumbnail_url')
.eq('id', id)
.single()
const existing = existingResult.data as { user_id: string | null; thumbnail_url: string | null } | null
if (!existing || existing.user_id !== session.user.id) {
return NextResponse.json({ error: 'Not found or forbidden' }, { status: 403 })
}
// Delete thumbnail from storage if present
if (existing.thumbnail_url) {
const url = existing.thumbnail_url as string
const match = url.match(/preset-thumbnails\/(.+)$/)
if (match?.[1]) {
await supabaseAdmin.storage.from('preset-thumbnails').remove([match[1]])
}
}
const { error } = await supabaseAdmin.from('presets').delete().eq('id', id)
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json({ success: true })
}
@@ -1,61 +0,0 @@
import { NextRequest, NextResponse } from 'next/server'
import { headers } from 'next/headers'
import { auth } from '@/lib/auth'
import { supabaseAdmin } from '@/lib/supabase/server'
// POST /api/presets/[id]/thumbnail
// Accepts a raw PNG blob, uploads to preset-thumbnails bucket, updates thumbnail_url
export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
const session = await auth.api.getSession({ headers: await headers() })
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id } = await params
const existingResult = await supabaseAdmin
.from('presets')
.select('user_id')
.eq('id', id)
.single()
const existing = existingResult.data as { user_id: string | null } | null
if (!existing || existing.user_id !== session.user.id) {
return NextResponse.json({ error: 'Not found or forbidden' }, { status: 403 })
}
const blob = await req.blob()
const filename = `${id}/thumbnail.png`
const { data: uploadData, error: uploadError } = await supabaseAdmin.storage
.from('preset-thumbnails')
.upload(filename, blob, {
contentType: 'image/png',
upsert: true,
})
if (uploadError) {
return NextResponse.json({ error: uploadError.message }, { status: 500 })
}
const { data: urlData } = supabaseAdmin.storage
.from('preset-thumbnails')
.getPublicUrl(uploadData.path)
const thumbnailUrl = `${urlData.publicUrl}?t=${Date.now()}`
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { error: updateError } = await (supabaseAdmin as any)
.from('presets')
.update({ thumbnail_url: thumbnailUrl })
.eq('id', id)
if (updateError) {
return NextResponse.json({ error: updateError.message }, { status: 500 })
}
return NextResponse.json({ thumbnail_url: thumbnailUrl })
}
-81
View File
@@ -1,81 +0,0 @@
import { NextRequest, NextResponse } from 'next/server'
import { headers } from 'next/headers'
import { auth } from '@/lib/auth'
import { supabaseAdmin } from '@/lib/supabase/server'
import { createId } from '@pascal-app/db'
// GET /api/presets?type=door|window&tab=community|mine
export async function GET(req: NextRequest) {
const { searchParams } = req.nextUrl
const type = searchParams.get('type')
const tab = searchParams.get('tab') ?? 'community'
if (!type || (type !== 'door' && type !== 'window')) {
return NextResponse.json({ error: 'Invalid type' }, { status: 400 })
}
if (tab === 'mine') {
const session = await auth.api.getSession({ headers: await headers() })
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { data, error } = await supabaseAdmin
.from('presets')
.select('*')
.eq('type', type)
.eq('user_id', session.user.id)
.order('created_at', { ascending: false })
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json({ presets: data })
}
// community tab
const { data, error } = await supabaseAdmin
.from('presets')
.select('*')
.eq('type', type)
.eq('is_community', true)
.order('created_at', { ascending: false })
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json({ presets: data })
}
// POST /api/presets
export async function POST(req: NextRequest) {
const session = await auth.api.getSession({ headers: await headers() })
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body = await req.json()
const { type, name, data, thumbnailUrl } = body
if (!type || !name || !data) {
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 })
}
if (type !== 'door' && type !== 'window') {
return NextResponse.json({ error: 'Invalid type' }, { status: 400 })
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { data: preset, error } = await (supabaseAdmin as any)
.from('presets')
.insert({
id: createId('preset'),
type,
name,
data,
thumbnail_url: thumbnailUrl ?? null,
user_id: session.user.id,
is_community: false,
})
.select()
.single()
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json({ preset }, { status: 201 })
}
@@ -1,42 +0,0 @@
'use client'
import Link from 'next/link'
import { useEffect } from 'react'
export default function EditorRouteError({
error,
reset,
}: Readonly<{
error: Error & { digest?: string }
reset: () => void
}>) {
useEffect(() => {
console.error('[editor-route] Unhandled editor error:', error)
}, [error])
return (
<div className="flex min-h-screen w-full items-center justify-center bg-background p-4 text-foreground">
<div className="w-full max-w-md rounded-2xl border border-border/60 bg-background p-6 shadow-xl">
<h1 className="text-lg font-semibold">Editor error</h1>
<p className="mt-2 text-sm text-muted-foreground">
We couldn&apos;t load this editor route. You can retry or return home.
</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"
onClick={reset}
type="button"
>
Try again
</button>
<Link
className="rounded-md border border-border bg-background px-3 py-2 text-sm font-medium hover:bg-accent/40"
href="/"
>
Back to home
</Link>
</div>
</div>
</div>
)
}
@@ -1,22 +0,0 @@
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: 'Editor Workspace',
description: 'Edit your Pascal projects in a full 3D workspace.',
robots: {
index: false,
follow: false,
},
}
export default function EditorProjectLayout({
children,
}: Readonly<{
children: React.ReactNode
}>) {
return (
<div style={{ cursor: "url('/cursor.svg') 4 2, default" }}>
{children}
</div>
)
}
@@ -1,49 +0,0 @@
'use client'
import { useParams, useRouter } from 'next/navigation'
import { useEffect, useState } from 'react'
import Editor from '@/components/editor'
import { SceneLoader } from '@/components/ui/scene-loader'
import { useAuth } from '@/features/community/lib/auth/hooks'
import { useProjectStore } from '@/features/community/lib/projects/store'
export default function EditorPage() {
const params = useParams()
const projectId = params.projectId as string
const { isAuthenticated, isLoading } = useAuth()
const setActiveProject = useProjectStore((state) => state.setActiveProject)
const router = useRouter()
const [mounted, setMounted] = useState(false)
useEffect(() => {
setMounted(true)
}, [])
// Use layoutEffect to set active project BEFORE the editor renders and hooks run
useEffect(() => {
if (isLoading) return
if (!isAuthenticated) {
router.replace('/')
return
}
if (projectId) {
setActiveProject(projectId)
}
}, [projectId, isAuthenticated, isLoading, setActiveProject, router])
if (!mounted || isLoading) {
return <SceneLoader fullScreen />
}
if (!isAuthenticated) {
return null
}
return (
<div className="flex h-screen w-full max-w-screen">
<div className="relative h-full w-full">
<Editor projectId={projectId} />
</div>
</div>
)
}
+1
View File
@@ -1,5 +1,6 @@
@import "tailwindcss";
@import "tw-animate-css";
@source "../../../packages/editor/src";
@custom-variant dark (&:is(.dark *));
+3 -53
View File
@@ -2,11 +2,6 @@ import type { Metadata } from 'next'
import Script from 'next/script'
import localFont from 'next/font/local'
import { Barlow } from 'next/font/google'
import { Analytics } from '@vercel/analytics/react'
import { SpeedInsights } from '@vercel/speed-insights/next'
import { VercelToolbar } from '@vercel/toolbar/next'
import { UsernameGate } from '@/features/community/components/username-gate'
import { siteConfig } from './seo'
import './globals.css'
const geistSans = localFont({
@@ -26,48 +21,8 @@ const barlow = Barlow({
})
export const metadata: Metadata = {
metadataBase: new URL(siteConfig.url),
title: {
default: siteConfig.name,
template: '%s | Pascal Editor',
},
description: siteConfig.description,
applicationName: siteConfig.name,
keywords: [...siteConfig.keywords],
authors: [{ name: 'Pascal', url: 'https://pascal.app' }],
creator: 'Pascal',
publisher: 'Pascal',
alternates: {
canonical: '/',
},
icons: [{ rel: 'icon', url: '/favicon.ico' }],
openGraph: {
title: siteConfig.name,
description: siteConfig.description,
url: siteConfig.url,
siteName: siteConfig.name,
images: [{ url: siteConfig.ogImage, alt: 'Pascal Editor' }],
locale: 'en_US',
type: 'website',
},
twitter: {
card: 'summary_large_image',
title: siteConfig.name,
description: siteConfig.description,
creator: siteConfig.twitterHandle,
images: [siteConfig.ogImage],
},
robots: {
index: true,
follow: true,
googleBot: {
index: true,
follow: true,
'max-image-preview': 'large',
'max-snippet': -1,
'max-video-preview': -1,
},
},
title: 'Pascal Editor',
description: 'Standalone building editor',
}
export default function RootLayout({
@@ -75,8 +30,6 @@ export default function RootLayout({
}: Readonly<{
children: React.ReactNode
}>) {
const shouldShowToolbar = process.env.NODE_ENV === 'development'
return (
<html lang="en" className={`${geistSans.variable} ${geistMono.variable} ${barlow.variable}`}>
<head>
@@ -96,10 +49,7 @@ export default function RootLayout({
)}
</head>
<body className="font-sans">
<UsernameGate>{children}</UsernameGate>
<Analytics />
<SpeedInsights />
{shouldShowToolbar && <VercelToolbar />}
{children}
</body>
</html>
)
-163
View File
@@ -1,163 +0,0 @@
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
import { ImageResponse } from 'next/og'
import * as z from 'zod'
import { siteConfig } from '@/app/seo'
const ogImageSchema = z.object({
title: z.string().default(''),
description: z.string().default(''),
theme: z.enum(['light', 'dark']).default('dark'),
})
let sansFont: ArrayBuffer | null = null
let jakartaFont: ArrayBuffer | null = null
async function loadFonts() {
if (!(sansFont && jakartaFont)) {
const fontDir = join(process.cwd(), 'public', 'fonts')
const [sans, jakarta] = await Promise.all([
readFile(join(fontDir, 'geist-regular.ttf')),
readFile(join(fontDir, 'PlusJakartaSans-SemiBold.ttf')),
])
sansFont = sans.buffer.slice(sans.byteOffset, sans.byteOffset + sans.byteLength)
jakartaFont = jakarta.buffer.slice(jakarta.byteOffset, jakarta.byteOffset + jakarta.byteLength)
}
return { sans: sansFont, jakarta: jakartaFont }
}
function PascalLogo({ color = '#fff', size = 100 }: { color?: string; size?: number }) {
return (
<svg fill="none" height={size} style={{ display: 'flex' }} viewBox="0 0 100 100" width={size}>
<rect fill={color} height="40" width="20" y="60" />
<rect fill={color} height="40" width="20" x="40" y="30" />
<rect fill={color} height="40" width="20" x="80" />
</svg>
)
}
export async function GET(req: Request) {
try {
const url = new URL(req.url)
const values = ogImageSchema.parse(Object.fromEntries(url.searchParams))
const heading =
values.title.length > 140 ? `${values.title.substring(0, 140)}...` : values.title
const { theme } = values
const paint = theme === 'dark' ? '#fff' : '#000'
const logoColor = theme === 'dark' ? 'rgba(255, 255, 255, 0.9)' : 'rgba(0, 0, 0, 0.9)'
const fontSize = heading.length > 100 ? '70px' : '100px'
const showLargeLogo = !(values.title || values.description)
return new ImageResponse(
<div
style={{
color: paint,
background:
theme === 'dark'
? 'linear-gradient(135deg, #0a0a0a 0%, #1a1a1a 50%, #0f0f0f 100%)'
: 'white',
}}
tw="flex relative flex-col w-full h-full items-start bg-cover"
>
{showLargeLogo ? (
<div tw="flex flex-col flex-1 py-10 px-12 h-full justify-center">
<div style={{ display: 'flex', marginBottom: 24 }}>
<PascalLogo color={logoColor} size={80} />
</div>
<div
style={{
fontFamily: 'jakarta-semibold',
fontWeight: 'bolder',
fontSize: '72px',
letterSpacing: '-0.02em',
color: paint,
display: 'flex',
}}
>
Pascal Editor
</div>
<div
style={{
fontFamily: 'sans-regular',
fontWeight: 'normal',
fontSize: '28px',
letterSpacing: '0.05em',
color: 'rgba(255, 255, 255, 0.6)',
marginTop: 16,
display: 'flex',
}}
>
{siteConfig.description}
</div>
</div>
) : (
<div tw="flex flex-col flex-1 py-10 px-12 h-full">
<div style={{ display: 'flex', marginBottom: 24 }}>
<PascalLogo color={logoColor} size={40} />
</div>
<div
style={{
fontFamily: 'jakarta-semibold',
fontWeight: 'bolder',
marginLeft: '-3px',
fontSize,
letterSpacing: '.05rem',
}}
tw="flex leading-[1.1] text-[80px] tracking-tighter font-sans mb-4"
>
{heading}
</div>
<div
style={{
fontFamily: 'sans-regular',
fontWeight: 'normal',
letterSpacing: '.1rem',
}}
tw="flex flex-1 text-[50px] tracking-tight font-sans"
>
{values.description}
</div>
</div>
)}
{!showLargeLogo && (
<div
style={{
fontFamily: 'sans-regular',
fontWeight: 'normal',
letterSpacing: '.1rem',
}}
tw="w-full pt-24 p-10 text-[30px] tracking-tight font-sans text-right w-full"
>
editor.pascal.app
</div>
)}
</div>,
{
width: 1200,
height: 630,
fonts: await loadFonts().then(({ sans, jakarta }) => [
{
name: 'sans-regular',
data: sans,
style: 'normal' as const,
weight: 400 as const,
},
{
name: 'jakarta-semibold',
data: jakarta,
style: 'normal' as const,
weight: 600 as const,
},
]),
},
)
} catch (error) {
console.log('error', error)
return new Response('Failed to generate image', {
status: 500,
})
}
}
+6 -9
View File
@@ -1,12 +1,9 @@
import type { Metadata } from 'next'
import CommunityHub from '@/features/community/components/community-hub'
export const metadata: Metadata = {
title: 'Community Projects',
description:
'Create and share 3D home projects with Pascal Editor, the open-source building editor.',
}
import { Editor } from '@pascal-app/editor'
export default function Home() {
return <CommunityHub />
return (
<div className="h-screen w-screen">
<Editor />
</div>
)
}
-16
View File
@@ -1,16 +0,0 @@
import type { MetadataRoute } from 'next'
import { siteConfig } from './seo'
export default function robots(): MetadataRoute.Robots {
return {
rules: [
{
userAgent: '*',
allow: ['/', '/viewer/', '/u/'],
disallow: ['/api/', '/editor/', '/settings', '/_next/'],
},
],
sitemap: `${siteConfig.url}/sitemap.xml`,
host: siteConfig.url,
}
}
-22
View File
@@ -1,22 +0,0 @@
import { BASE_URL } from '@/lib/utils'
export const siteConfig = {
name: 'Pascal Editor',
description:
'Pascal Editor is an open-source 3D building editor for designing, editing, and sharing home projects.',
url: BASE_URL,
website: 'editor.pascal.app',
ogImage: '/og',
keywords: [
'Pascal Editor',
'open-source 3D editor',
'3D building editor',
'home design software',
'architecture editor',
'collaborative design',
],
twitterHandle: '@pascal_app',
links: {
github: 'https://github.com/pascalorg/editor',
},
} as const
-40
View File
@@ -1,40 +0,0 @@
export const dynamic = 'force-dynamic'
import type { Metadata } from 'next'
import { redirect } from 'next/navigation'
import { getSession } from '@/features/community/lib/auth/server'
import { getUserProfile, getConnectedAccounts } from '@/features/community/lib/auth/actions'
import { SettingsPage } from '@/features/community/components/settings-page'
export const metadata: Metadata = {
title: 'Account Settings',
description: 'Manage your Pascal Editor account profile and connected providers.',
robots: {
index: false,
follow: false,
},
}
export default async function Settings() {
const session = await getSession()
if (!session?.user) {
redirect('/')
}
const [profile, connectedAccounts] = await Promise.all([
getUserProfile(),
getConnectedAccounts(),
])
return (
<SettingsPage
user={session.user}
currentUsername={profile?.username ?? null}
currentGithubUrl={profile?.githubUrl ?? null}
currentXUrl={profile?.xUrl ?? null}
currentYoutubeUrl={profile?.youtubeUrl ?? null}
currentEmailNotifications={profile?.emailNotifications ?? true}
connectedAccounts={connectedAccounts}
/>
)
}
-22
View File
@@ -1,22 +0,0 @@
import type { MetadataRoute } from 'next'
import { siteConfig } from './seo'
export default function sitemap(): MetadataRoute.Sitemap {
const currentDate = new Date()
const baseUrl = siteConfig.url
return [
{
url: baseUrl,
lastModified: currentDate,
changeFrequency: 'daily',
priority: 1,
},
{
url: `${baseUrl}/viewer/demo_1`,
lastModified: currentDate,
changeFrequency: 'weekly',
priority: 0.7,
},
]
}
-45
View File
@@ -1,45 +0,0 @@
export const dynamic = 'force-dynamic'
import type { Metadata } from 'next'
import { notFound } from 'next/navigation'
import { getPublicProfile } from '@/features/community/lib/auth/actions'
import { getPublicProjectsByUserId } from '@/features/community/lib/projects/actions'
import { PublicProfilePage } from '@/features/community/components/public-profile-page'
export async function generateMetadata({
params,
}: {
params: Promise<{ username: string }>
}): Promise<Metadata> {
const { username } = await params
return {
title: `${username}'s Projects`,
description: `Public projects shared by @${username} on Pascal Editor.`,
alternates: {
canonical: `/u/${encodeURIComponent(username)}`,
},
}
}
export default async function ProfilePage({
params,
}: {
params: Promise<{ username: string }>
}) {
const { username } = await params
const profileResult = await getPublicProfile(username)
if (!profileResult.success || !profileResult.data) {
notFound()
}
const projectsResult = await getPublicProjectsByUserId(profileResult.data.id)
return (
<PublicProfilePage
profile={profileResult.data}
projects={projectsResult.data ?? []}
/>
)
}
@@ -1,300 +0,0 @@
'use client'
import {
type AnyNodeId,
type CollectionId,
type Control,
type ControlValue,
type ItemNode,
useInteractive,
useScene,
} from '@pascal-app/core'
import { ChevronDown, ChevronRight } from 'lucide-react'
import { useMemo, useState } from 'react'
import { useShallow } from 'zustand/react/shallow'
import { cn } from '@/lib/utils'
// ─── Shared control derivation ───────────────────────────────────────────────
type ItemControlRef = { itemId: AnyNodeId; controlIndex: number }
type SharedControlDef = {
kind: 'toggle' | 'slider' | 'temperature'
label?: string
min?: number
max?: number
step?: number
unit?: string
refs: ItemControlRef[]
}
function deriveSharedControls(items: ItemNode[]): SharedControlDef[] {
if (items.length === 0) return []
const result: SharedControlDef[] = []
for (const kind of ['toggle', 'slider', 'temperature'] as const) {
const refs: ItemControlRef[] = []
let ref: Control | null = null
let allHave = true
for (const item of items) {
const idx = item.asset.interactive!.controls.findIndex((c) => c.kind === kind)
if (idx === -1) { allHave = false; break }
refs.push({ itemId: item.id, controlIndex: idx })
if (!ref) ref = item.asset.interactive!.controls[idx]!
}
if (!allHave || !ref) continue
const def: SharedControlDef = { kind, label: ref.label, refs }
if ('min' in ref) { def.min = ref.min; def.max = ref.max }
if ('step' in ref) def.step = (ref as { step?: number }).step
if ('unit' in ref) def.unit = (ref as { unit?: string }).unit
result.push(def)
}
return result
}
// ─── Shared control widget ───────────────────────────────────────────────────
function SharedWidget({ def, value, onChange }: { def: SharedControlDef; value: ControlValue; onChange: (v: ControlValue) => void }) {
if (def.kind === 'toggle') {
return (
<button
type="button"
onClick={() => onChange(!value)}
className={cn(
'flex h-7 w-full items-center justify-center rounded-md px-3 text-xs font-medium transition-colors',
value ? 'bg-green-500/20 text-green-400' : 'bg-white/10 text-muted-foreground hover:text-foreground',
)}
>
{def.label ?? (value ? 'On' : 'Off')}
</button>
)
}
return (
<div className="flex flex-col gap-1">
<div className="flex items-center justify-between text-[10px] text-muted-foreground">
<span>{def.label ?? def.kind}</span>
<span>{value}{def.kind === 'temperature' ? '°' : ''}{def.unit ? ` ${def.unit}` : ''}</span>
</div>
<input
type="range"
min={def.min}
max={def.max}
step={def.step ?? 1}
value={value as number}
onChange={(e) => onChange(Number(e.target.value))}
onPointerDown={(e) => e.stopPropagation()}
className="w-full accent-white"
/>
</div>
)
}
// ─── Individual item control widget ──────────────────────────────────────────
function ItemWidget({ control, value, onChange }: { control: Control; value: ControlValue; onChange: (v: ControlValue) => void }) {
if (control.kind === 'toggle') {
return (
<button
type="button"
onClick={() => onChange(!value)}
className={cn(
'flex h-6 w-full items-center justify-center rounded px-2 text-[10px] font-medium transition-colors',
value ? 'bg-green-500/20 text-green-400' : 'bg-white/5 text-muted-foreground hover:text-foreground',
)}
>
{control.label ?? (value ? 'On' : 'Off')}
</button>
)
}
return (
<div className="flex flex-col gap-0.5">
<div className="flex items-center justify-between text-[10px] text-muted-foreground">
<span>{control.label ?? control.kind}</span>
<span>{value}{control.kind === 'temperature' ? '°' : ''}{'unit' in control && control.unit ? ` ${control.unit}` : ''}</span>
</div>
<input
type="range"
min={'min' in control ? control.min : 0}
max={'max' in control ? control.max : 100}
step={'step' in control ? (control as { step?: number }).step ?? 1 : 1}
value={value as number}
onChange={(e) => onChange(Number(e.target.value))}
onPointerDown={(e) => e.stopPropagation()}
className="w-full accent-white"
/>
</div>
)
}
// ─── Collection row ───────────────────────────────────────────────────────────
function CollectionRow({ collectionId }: { collectionId: CollectionId }) {
const collection = useScene((s) => s.collections[collectionId])
const interactiveItems = useScene(
useShallow((s) =>
(collection?.nodeIds ?? [])
.map((id) => s.nodes[id])
.filter((n): n is ItemNode => n?.type === 'item' && !!n.asset.interactive)
),
)
const allItems = useInteractive((s) => s.items)
const controlValuesByItem = useMemo(
() => Object.fromEntries(interactiveItems.map((n) => [n.id, allItems[n.id]?.controlValues ?? []])),
[allItems, interactiveItems],
)
const setControlValue = useInteractive((s) => s.setControlValue)
const [expanded, setExpanded] = useState(false)
const [expandedItemIds, setExpandedItemIds] = useState<Set<AnyNodeId>>(new Set())
if (!collection) return null
const sharedControls = deriveSharedControls(interactiveItems)
const getSharedValue = (def: SharedControlDef): ControlValue => {
if (def.kind === 'toggle') {
return def.refs.every(({ itemId, controlIndex }) => Boolean(controlValuesByItem[itemId]?.[controlIndex]))
}
const first = def.refs[0]!
return controlValuesByItem[first.itemId]?.[first.controlIndex] ?? 0
}
const setSharedValue = (def: SharedControlDef, value: ControlValue) => {
for (const { itemId, controlIndex } of def.refs) {
setControlValue(itemId, controlIndex, value)
}
}
const toggleItemExpand = (id: AnyNodeId) => {
setExpandedItemIds((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
return (
<div>
{/* Header */}
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="flex w-full items-center gap-2 px-3 py-2.5 hover:bg-white/5 transition-colors"
>
<span
className="h-2.5 w-2.5 shrink-0 rounded-sm border border-white/20"
style={{ backgroundColor: collection.color ?? '#6366f1' }}
/>
<span className="flex-1 min-w-0 text-xs font-medium text-foreground truncate text-left">
{collection.name}
</span>
{interactiveItems.length > 0 && (
<span className="text-[10px] text-muted-foreground shrink-0">
{interactiveItems.length}
</span>
)}
{expanded
? <ChevronDown className="h-3 w-3 shrink-0 text-muted-foreground" />
: <ChevronRight className="h-3 w-3 shrink-0 text-muted-foreground" />}
</button>
{/* Expanded */}
{expanded && (
<div className="pb-1">
{interactiveItems.length === 0 ? (
<p className="px-3 pb-2 text-[11px] text-muted-foreground">No interactive items.</p>
) : (
<>
{/* Shared controls */}
{sharedControls.length > 0 && (
<div className="px-3 pt-0.5 pb-2.5 border-b border-border/30">
<p className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground mb-2">All</p>
<div className="flex flex-col gap-2">
{sharedControls.map((def, i) => (
<SharedWidget
key={i}
def={def}
value={getSharedValue(def)}
onChange={(v) => setSharedValue(def, v)}
/>
))}
</div>
</div>
)}
{/* Individual items */}
{interactiveItems.map((item) => {
const isItemExpanded = expandedItemIds.has(item.id)
const controls = item.asset.interactive!.controls
const values = controlValuesByItem[item.id] ?? []
return (
<div key={item.id}>
<button
type="button"
onClick={() => toggleItemExpand(item.id)}
className="flex w-full items-center gap-1.5 px-3 py-1.5 hover:bg-white/5 transition-colors"
>
{isItemExpanded
? <ChevronDown className="h-2.5 w-2.5 shrink-0 text-muted-foreground/50" />
: <ChevronRight className="h-2.5 w-2.5 shrink-0 text-muted-foreground/50" />}
<span className="flex-1 min-w-0 text-[11px] text-muted-foreground truncate text-left">
{item.name || item.asset.name}
</span>
</button>
{isItemExpanded && (
<div className="px-3 pb-2 flex flex-col gap-1.5">
{controls.map((control, i) => (
<ItemWidget
key={i}
control={control}
value={values[i] ?? false}
onChange={(v) => setControlValue(item.id, i, v)}
/>
))}
</div>
)}
</div>
)
})}
</>
)}
</div>
)}
</div>
)
}
// ─── Main panel ───────────────────────────────────────────────────────────────
export function CollectionsPanel() {
const collectionIds = useScene(
useShallow((s) => Object.keys(s.collections) as CollectionId[]),
)
if (collectionIds.length === 0) return null
return (
<div className="pointer-events-auto flex flex-col rounded-2xl border border-border/40 bg-background/95 shadow-lg backdrop-blur-xl overflow-hidden w-56">
<div className="px-3 py-2 border-b border-border/40 shrink-0">
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">Collections</span>
</div>
<div className="overflow-y-auto max-h-[70vh] no-scrollbar divide-y divide-border/30">
{collectionIds.map((id) => (
<CollectionRow key={id} collectionId={id} />
))}
</div>
</div>
)
}
-42
View File
@@ -1,42 +0,0 @@
'use client'
import Link from 'next/link'
import { useEffect } from 'react'
export default function ViewerRouteError({
error,
reset,
}: Readonly<{
error: Error & { digest?: string }
reset: () => void
}>) {
useEffect(() => {
console.error('[viewer-route] Unhandled viewer error:', error)
}, [error])
return (
<div className="flex min-h-screen w-full items-center justify-center bg-background p-4 text-foreground">
<div className="w-full max-w-md rounded-2xl border border-border/60 bg-background p-6 shadow-xl">
<h1 className="text-lg font-semibold">Viewer error</h1>
<p className="mt-2 text-sm text-muted-foreground">
We couldn&apos;t load this project view. You can retry without leaving the app.
</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"
onClick={reset}
type="button"
>
Try again
</button>
<Link
className="rounded-md border border-border bg-background px-3 py-2 text-sm font-medium hover:bg-accent/40"
href="/"
>
Back to home
</Link>
</div>
</div>
</div>
)
}
-14
View File
@@ -1,14 +0,0 @@
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: 'Project Viewer',
description: 'View and share 3D projects built with Pascal Editor.',
}
export default function ViewerLayout({
children,
}: Readonly<{
children: React.ReactNode
}>) {
return children
}
-190
View File
@@ -1,190 +0,0 @@
'use client'
import { initSpatialGridSync, useScene } from '@pascal-app/core'
import { InteractiveSystem, useViewer, Viewer } from '@pascal-app/viewer'
import Link from 'next/link'
import { useParams } from 'next/navigation'
import { useEffect, useState } from 'react'
import { ErrorBoundary } from '@/components/ui/primitives/error-boundary'
import { SceneLoader } from '@/components/ui/scene-loader'
import {
getProjectModelPublic,
incrementProjectViews,
} from '@/features/community/lib/projects/actions'
import type { ProjectOwner } from '@/features/community/lib/projects/types'
import { ViewerCameraControls } from './viewer-camera-controls'
import { ViewerGuestCTA } from './viewer-guest-cta'
import { ViewerOverlay } from './viewer-overlay'
import { ViewerZoneSystem } from './viewer-zone-system'
function ViewerSceneCrashFallback({ projectName }: { projectName?: string | null }) {
return (
<div className="absolute inset-0 z-30 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 3D scene failed to render</h2>
<p className="mt-2 text-sm text-muted-foreground">
{projectName ? `"${projectName}" ` : ''}
hit a rendering error. The rest of the app is still available.
</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"
onClick={() => window.location.reload()}
type="button"
>
Reload scene
</button>
<Link
className="rounded-md border border-border bg-background px-3 py-2 text-sm font-medium hover:bg-accent/40"
href="/"
>
Back to home
</Link>
</div>
</div>
</div>
)
}
export default function ViewerPage() {
const params = useParams()
const id = params.id as string
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [projectId, setProjectId] = useState<string | null>(null)
const [projectName, setProjectName] = useState<string | null>(null)
const [owner, setOwner] = useState<ProjectOwner | null>(null)
const [canShowScans, setCanShowScans] = useState(true)
const [canShowGuides, setCanShowGuides] = useState(true)
const setScene = useScene((state) => state.setScene)
useEffect(() => {
useViewer.getState().setProjectId(projectId)
}, [projectId])
useEffect(() => {
let cancelled = false
setLoading(true)
setError(null)
setProjectId(null)
setProjectName(null)
setOwner(null)
setCanShowScans(true)
setCanShowGuides(true)
useViewer.getState().setShowScans(true)
useViewer.getState().setShowGuides(true)
const loadContent = async () => {
try {
// Check if it's a demo file (starts with 'demo_')
if (id.startsWith('demo_')) {
const response = await fetch(`/demos/${id}.json`)
if (cancelled) return
if (!response.ok) {
throw new Error(`Demo "${id}" not found`)
}
const data = await response.json()
if (cancelled) return
if (data.nodes && data.rootNodeIds) {
setScene(data.nodes, data.rootNodeIds)
initSpatialGridSync()
}
setProjectName('Demo')
} else {
// Load from database (public project)
const result = await getProjectModelPublic(id)
if (cancelled) return
if (result.success && result.data) {
const { project, model, isOwner } = result.data
const projectData = project as any
setProjectId(project.id)
setProjectName(project.name)
setOwner(projectData.owner ?? null)
// Apply public visibility settings for scans/guides (only for non-owners)
if (!isOwner) {
const scansAllowed = projectData.show_scans_public !== false
const guidesAllowed = projectData.show_guides_public !== false
setCanShowScans(scansAllowed)
setCanShowGuides(guidesAllowed)
if (!scansAllowed) {
useViewer.getState().setShowScans(false)
}
if (!guidesAllowed) {
useViewer.getState().setShowGuides(false)
}
}
if (model?.scene_graph) {
const { nodes, rootNodeIds } = model.scene_graph
setScene(nodes, rootNodeIds)
initSpatialGridSync()
}
// Increment view count
await incrementProjectViews(id)
if (cancelled) return
} else {
throw new Error(result.error || 'Project not found')
}
}
if (!cancelled) {
setLoading(false)
}
} catch (err) {
if (!cancelled) {
setError(err instanceof Error ? err.message : 'Failed to load content')
setLoading(false)
}
}
}
loadContent()
return () => {
cancelled = true
}
}, [id, setScene])
if (error) {
return (
<div className="flex h-screen w-full items-center justify-center bg-neutral-100">
<p className="text-destructive">{error}</p>
</div>
)
}
return (
<div className="relative h-screen w-full">
{loading && <SceneLoader fullScreen />}
{!loading && (
<>
<ViewerOverlay
projectName={projectName}
owner={owner}
canShowScans={canShowScans}
canShowGuides={canShowGuides}
/>
<ViewerGuestCTA />
<ErrorBoundary key={id} fallback={<ViewerSceneCrashFallback projectName={projectName} />}>
<Viewer>
<ViewerCameraControls />
<ViewerZoneSystem />
<InteractiveSystem />
</Viewer>
</ErrorBoundary>
</>
)}
</div>
)
}
@@ -1,178 +0,0 @@
'use client'
import { emitter, sceneRegistry, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { CameraControls, CameraControlsImpl } from '@react-three/drei'
import { useCallback, useEffect, useMemo, useRef } from 'react'
import { Box3, Vector3 } from 'three'
const tempBox = new Box3()
const tempCenter = new Vector3()
const tempSize = new Vector3()
export const ViewerCameraControls = () => {
const controls = useRef<CameraControlsImpl>(null!)
const selection = useViewer((s) => s.selection)
const nodes = useScene((s) => s.nodes)
const cameraMode = useViewer((s) => s.cameraMode)
const firstLoad = useRef(true)
// Get the deepest selected node ID (excluding selectedIds)
const targetNodeId = selection.zoneId ?? selection.levelId ?? selection.buildingId
// Configure mouse buttons - same as editor
const mouseButtons = useMemo(() => {
const wheelAction =
cameraMode === 'orthographic'
? CameraControlsImpl.ACTION.ZOOM
: CameraControlsImpl.ACTION.DOLLY
return {
left: CameraControlsImpl.ACTION.SCREEN_PAN,
middle: CameraControlsImpl.ACTION.SCREEN_PAN,
right: CameraControlsImpl.ACTION.ROTATE,
wheel: wheelAction,
}
}, [cameraMode])
useEffect(() => {
if (!controls.current) return
// On first load, set a default camera position
if (firstLoad.current) {
firstLoad.current = false
controls.current.setLookAt(30, 30, 30, 0, 0, 0, false)
}
let node = targetNodeId ? nodes[targetNodeId] : null;
if (!targetNodeId) {
const site = Object.values(nodes).find((n) => n.type === 'site')
node = site || null
}
if (!node) return
// Check if node has a saved camera
if (node.camera) {
const { position, target } = node.camera
requestAnimationFrame(() => {
controls.current.setLookAt(
position[0],
position[1],
position[2],
target[0],
target[1],
target[2],
true,
)
})
return
}
if (!targetNodeId) {
// No selection and no site - do nothing
return
}
// Calculate camera position based on the node's 3D object
const object3D = sceneRegistry.nodes.get(targetNodeId)
if (!object3D) return
// Compute bounding box
tempBox.setFromObject(object3D)
tempBox.getCenter(tempCenter)
tempBox.getSize(tempSize)
// Calculate a good viewing distance based on the object size
const maxDim = Math.max(tempSize.x, tempSize.y, tempSize.z)
const distance = Math.max(maxDim * 2, 15)
// Position camera at an angle looking at the center
const cameraPos = new Vector3(
tempCenter.x + distance * 0.7,
tempCenter.y + distance * 0.5,
tempCenter.z + distance * 0.7,
)
controls.current.setLookAt(
cameraPos.x,
cameraPos.y,
cameraPos.z,
tempCenter.x,
tempCenter.y,
tempCenter.z,
true,
)
}, [targetNodeId, nodes])
useEffect(() => {
const handleTopView = () => {
if (!controls.current) return
const currentPolarAngle = controls.current.polarAngle
// Toggle: if already near top view (< 0.1 radians ≈ 5.7°), go back to 45°
// Otherwise, go to top view (0°)
const targetAngle = currentPolarAngle < 0.1 ? Math.PI / 4 : 0
controls.current.rotatePolarTo(targetAngle, true)
}
const handleOrbitCW = () => {
if (!controls.current) return
const currentAzimuth = controls.current.azimuthAngle
const currentPolar = controls.current.polarAngle
// Round to nearest 90° increment, then rotate 90° clockwise
const rounded = Math.round(currentAzimuth / (Math.PI / 2)) * (Math.PI / 2)
const target = rounded - Math.PI / 2
controls.current.rotateTo(target, currentPolar, true)
}
const handleOrbitCCW = () => {
if (!controls.current) return
const currentAzimuth = controls.current.azimuthAngle
const currentPolar = controls.current.polarAngle
// Round to nearest 90° increment, then rotate 90° counter-clockwise
const rounded = Math.round(currentAzimuth / (Math.PI / 2)) * (Math.PI / 2)
const target = rounded + Math.PI / 2
controls.current.rotateTo(target, currentPolar, true)
}
emitter.on('camera-controls:top-view', handleTopView)
emitter.on('camera-controls:orbit-cw', handleOrbitCW)
emitter.on('camera-controls:orbit-ccw', handleOrbitCCW)
return () => {
emitter.off('camera-controls:top-view', handleTopView)
emitter.off('camera-controls:orbit-cw', handleOrbitCW)
emitter.off('camera-controls:orbit-ccw', handleOrbitCCW)
}
}, [])
const onTransitionStart = useCallback(() => {
useViewer.getState().setCameraDragging(true)
}, [])
const onRest = useCallback(() => {
useViewer.getState().setCameraDragging(false)
}, [])
return (
<CameraControls
ref={controls}
maxDistance={100}
minDistance={5}
maxPolarAngle={Math.PI / 2 - 0.1}
minPolarAngle={0}
mouseButtons={mouseButtons}
onTransitionStart={onTransitionStart}
onRest={onRest}
restThreshold={0.01}
/>
)
}
@@ -1,29 +0,0 @@
'use client'
import { useState } from 'react'
import { useAuth } from '@/features/community/lib/auth/hooks'
import { SignInDialog } from '@/features/community/components/sign-in-dialog'
export function ViewerGuestCTA() {
const { isAuthenticated, isLoading } = useAuth()
const [showSignIn, setShowSignIn] = useState(false)
if (isLoading || isAuthenticated) return null
return (
<>
<div className="absolute top-4 right-4 z-20 dark text-foreground">
<div className="pointer-events-auto bg-background/95 backdrop-blur-xl border border-border/40 rounded-2xl px-6 py-3 shadow-lg transition-colors duration-200 ease-out flex flex-col sm:flex-row items-center gap-4">
<p className="text-sm font-medium text-foreground text-center">Want to create your own 3D project?</p>
<button
onClick={() => setShowSignIn(true)}
className="rounded-lg bg-primary px-4 py-2 text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors whitespace-nowrap w-full sm:w-auto"
>
Get Started
</button>
</div>
</div>
<SignInDialog open={showSignIn} onOpenChange={setShowSignIn} />
</>
)
}
@@ -1,444 +0,0 @@
'use client'
import {
type AnyNode,
type AnyNodeId,
type BuildingNode,
type LevelNode,
useScene,
type ZoneNode,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import {
ArrowLeft,
Camera,
ChevronRight,
Diamond,
Layers,
Layers2,
Moon,
Sun,
} from 'lucide-react'
import Link from 'next/link'
import { motion } from 'framer-motion'
import { cn } from '@/lib/utils'
import type { ProjectOwner } from '@/features/community/lib/projects/types'
import { ActionButton } from '@/components/ui/action-menu/action-button'
import { TooltipProvider } from '@/components/ui/primitives/tooltip'
import { emitter } from '@pascal-app/core'
import { CollectionsPanel } from './collections-panel'
const levelModeLabels: Record<'stacked' | 'exploded' | 'solo', string> = {
stacked: 'Stacked',
exploded: 'Exploded',
solo: 'Solo',
}
const wallModeConfig = {
up: {
icon: (props: any) => (
<img alt="Full Height" height={28} src="/icons/room.png" width={28} {...props} />
),
label: 'Full Height',
},
cutaway: {
icon: (props: any) => (
<img alt="Cutaway" height={28} src="/icons/wallcut.png" width={28} {...props} />
),
label: 'Cutaway',
},
down: {
icon: (props: any) => <img alt="Low" height={28} src="/icons/walllow.png" width={28} {...props} />,
label: 'Low',
},
}
const getNodeName = (node: AnyNode): string => {
if ('name' in node && node.name) return node.name
if (node.type === 'wall') return 'Wall'
if (node.type === 'item') return (node as { asset: { name: string } }).asset?.name || 'Item'
if (node.type === 'slab') return 'Slab'
if (node.type === 'ceiling') return 'Ceiling'
if (node.type === 'roof') return 'Roof'
return node.type
}
interface ViewerOverlayProps {
projectName?: string | null
owner?: ProjectOwner | null
canShowScans?: boolean
canShowGuides?: boolean
onBack?: () => void
hideCollections?: boolean
}
export const ViewerOverlay = ({
projectName,
owner,
canShowScans = true,
canShowGuides = true,
onBack,
hideCollections,
}: ViewerOverlayProps) => {
const selection = useViewer((s) => s.selection)
const nodes = useScene((s) => s.nodes)
const showScans = useViewer((s) => s.showScans)
const showGuides = useViewer((s) => s.showGuides)
const cameraMode = useViewer((s) => s.cameraMode)
const levelMode = useViewer((s) => s.levelMode)
const wallMode = useViewer((s) => s.wallMode)
const theme = useViewer((s) => s.theme)
const building = selection.buildingId
? (nodes[selection.buildingId] as BuildingNode | undefined)
: null
const level = selection.levelId ? (nodes[selection.levelId] as LevelNode | undefined) : null
const zone = selection.zoneId ? (nodes[selection.zoneId] as ZoneNode | undefined) : null
// Get the first selected item (if any)
const selectedNode =
selection.selectedIds.length > 0
? (nodes[selection.selectedIds[0] as AnyNodeId] as AnyNode | undefined)
: null
// Get all levels for the selected building
const levels =
building?.children
.map((id) => nodes[id as AnyNodeId] as LevelNode | undefined)
.filter((n): n is LevelNode => n?.type === 'level')
.sort((a, b) => a.level - b.level) ?? []
const handleLevelClick = (levelId: LevelNode['id']) => {
// When switching levels, deselect zone and items
useViewer.getState().setSelection({ levelId })
}
const handleBreadcrumbClick = (depth: 'root' | 'building' | 'level' | 'zone') => {
switch (depth) {
case 'root':
useViewer.getState().resetSelection()
break
case 'building':
useViewer.getState().setSelection({ levelId: null })
break
case 'level':
useViewer.getState().setSelection({ zoneId: null })
break
}
}
return (
<>
{/* Unified top-left card */}
<div className="absolute top-4 left-4 z-20 flex flex-col gap-3 dark text-foreground">
<div className="pointer-events-auto flex flex-col rounded-2xl border border-border/40 bg-background/95 shadow-lg backdrop-blur-xl transition-colors duration-200 ease-out overflow-hidden min-w-[200px]">
{/* Project info + back */}
<div className="flex items-center gap-3 px-3 py-2.5">
{onBack ? (
<button
onClick={onBack}
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md hover:bg-white/10 transition-colors"
>
<ArrowLeft className="h-4 w-4 text-muted-foreground" />
</button>
) : (
<Link
href="/"
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md hover:bg-white/10 transition-colors"
>
<ArrowLeft className="h-4 w-4 text-muted-foreground" />
</Link>
)}
<div className="min-w-0">
<div className="text-sm font-medium text-foreground truncate">
{projectName || 'Untitled'}
</div>
{owner?.username && (
<Link
href={`/u/${owner.username}`}
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
@{owner.username}
</Link>
)}
</div>
</div>
{/* Breadcrumb — only shown when navigated into a building */}
{building && (
<div className="border-t border-border/40 px-3 py-2">
<div className="flex items-center gap-1.5 text-xs">
<button
onClick={() => handleBreadcrumbClick('root')}
className="text-muted-foreground hover:text-foreground transition-colors"
>
Site
</button>
{building && (
<>
<ChevronRight className="w-3 h-3 text-muted-foreground/50" />
<button
onClick={() => handleBreadcrumbClick('building')}
className={`transition-colors truncate ${level ? 'text-muted-foreground hover:text-foreground' : 'text-foreground font-medium'}`}
>
{building.name || 'Building'}
</button>
</>
)}
{level && (
<>
<ChevronRight className="w-3 h-3 text-muted-foreground/50" />
<button
onClick={() => handleBreadcrumbClick('level')}
className={`transition-colors truncate ${zone ? 'text-muted-foreground hover:text-foreground' : 'text-foreground font-medium'}`}
>
{level.name || `Level ${level.level}`}
</button>
</>
)}
{zone && (
<>
<ChevronRight className="w-3 h-3 text-muted-foreground/50" />
<span
className={`transition-colors truncate ${selectedNode ? 'text-muted-foreground' : 'text-foreground font-medium'}`}
>
{zone.name}
</span>
</>
)}
{selectedNode && zone && (
<>
<ChevronRight className="w-3 h-3 text-muted-foreground/50" />
<span className="text-foreground font-medium truncate">
{getNodeName(selectedNode)}
</span>
</>
)}
</div>
</div>
)}
</div>
{/* Level List (only when building is selected) */}
{building && levels.length > 0 && (
<div className="pointer-events-auto flex flex-col rounded-2xl border border-border/40 bg-background/95 shadow-lg backdrop-blur-xl transition-colors duration-200 ease-out overflow-hidden w-48 py-1">
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider px-3 py-2">Levels</span>
<div className="flex flex-col">
{levels.map((lvl) => {
const isSelected = lvl.id === selection.levelId;
return (
<button
key={lvl.id}
onClick={() => handleLevelClick(lvl.id)}
className={cn(
"relative flex items-center h-8 w-full cursor-pointer group/row text-sm select-none border-b border-r border-border/50 border-r-transparent transition-all duration-200 px-3",
isSelected
? "bg-accent/50 text-foreground border-r-white border-r-3"
: "text-muted-foreground hover:bg-accent/30 hover:text-foreground"
)}
>
<div className="flex items-center gap-2 flex-1 min-w-0">
<span className={cn(
"w-4 h-4 flex items-center justify-center shrink-0 transition-all duration-200",
!isSelected && "opacity-60 grayscale"
)}>
<Layers className="w-3.5 h-3.5" />
</span>
<div className="flex-1 min-w-0 truncate text-left">
{lvl.name || `Level ${lvl.level}`}
</div>
</div>
</button>
);
})}
</div>
</div>
)}
</div>
{/* Collections Panel - Top Right */}
{!hideCollections && (
<div className="absolute top-4 right-4 z-20 flex flex-col gap-3 dark text-foreground">
<CollectionsPanel />
</div>
)}
{/* Controls Panel - Bottom Center */}
<div className="absolute bottom-6 left-1/2 -translate-x-1/2 z-20 dark text-foreground">
<TooltipProvider delayDuration={0}>
<div className="pointer-events-auto flex flex-row items-center justify-center gap-1.5 rounded-2xl border border-border/40 bg-background/95 p-1.5 shadow-lg backdrop-blur-xl transition-colors duration-200 ease-out h-14">
{/* Theme Toggle */}
<button
className="shrink-0 flex items-center bg-accent/50 rounded-full p-1 border border-border/50 cursor-pointer h-[36px]"
onClick={() => useViewer.getState().setTheme(theme === 'dark' ? 'light' : 'dark')}
type="button"
aria-label="Toggle theme"
>
<div className="relative flex">
{/* Sliding Background */}
<motion.div
className="absolute inset-0 bg-white shadow-sm rounded-full dark:bg-white/20"
initial={false}
animate={{
x: theme === "light" ? "100%" : "0%",
}}
transition={{
type: "spring",
stiffness: 500,
damping: 35,
}}
style={{ width: "50%" }}
/>
{/* Dark Mode Icon */}
<div
className={cn(
"relative z-10 flex h-7 w-9 items-center justify-center rounded-full transition-colors duration-200 pointer-events-none",
theme === "dark"
? "text-foreground"
: "text-muted-foreground"
)}
>
<Moon className="h-4 w-4" />
</div>
{/* Light Mode Icon */}
<div
className={cn(
"relative z-10 flex h-7 w-9 items-center justify-center rounded-full transition-colors duration-200 pointer-events-none",
theme === "light"
? "text-foreground"
: "text-muted-foreground"
)}
>
<Sun className="h-4 w-4" />
</div>
</div>
</button>
<div className="mx-1 h-5 w-px bg-border/40" />
{/* Scans and Guides Visibility */}
{canShowScans && (
<ActionButton
label={`Scans: ${showScans ? 'Visible' : 'Hidden'}`}
tooltipSide="top"
className={showScans ? 'bg-white/10' : 'opacity-60 grayscale hover:opacity-100 hover:grayscale-0 hover:bg-white/5'}
onClick={() => useViewer.getState().setShowScans(!showScans)}
size="icon"
variant="ghost"
>
<img alt="Scans" className="h-[28px] w-[28px] object-contain" src="/icons/mesh.png" />
</ActionButton>
)}
{canShowGuides && (
<ActionButton
label={`Guides: ${showGuides ? 'Visible' : 'Hidden'}`}
tooltipSide="top"
className={showGuides ? 'bg-white/10' : 'opacity-60 grayscale hover:opacity-100 hover:grayscale-0 hover:bg-white/5'}
onClick={() => useViewer.getState().setShowGuides(!showGuides)}
size="icon"
variant="ghost"
>
<img alt="Guides" className="h-[28px] w-[28px] object-contain" src="/icons/floorplan.png" />
</ActionButton>
)}
{(canShowScans || canShowGuides) && <div className="mx-1 h-5 w-px bg-border/40" />}
{/* Camera Mode */}
<ActionButton
label={`Camera: ${cameraMode === 'perspective' ? 'Perspective' : 'Orthographic'}`}
tooltipSide="top"
className={cameraMode === 'orthographic' ? 'bg-violet-500/20 text-violet-400' : 'hover:text-violet-400 hover:bg-white/5'}
onClick={() => useViewer.getState().setCameraMode(cameraMode === 'perspective' ? 'orthographic' : 'perspective')}
size="icon"
variant="ghost"
>
<Camera className="h-6 w-6" />
</ActionButton>
{/* Level Mode */}
<ActionButton
label={`Levels: ${levelMode === 'manual' ? 'Manual' : levelModeLabels[levelMode as keyof typeof levelModeLabels]}`}
tooltipSide="top"
className={levelMode !== 'stacked' ? 'bg-amber-500/20 text-amber-400' : 'hover:text-amber-400 hover:bg-white/5'}
onClick={() => {
if (levelMode === 'manual') return useViewer.getState().setLevelMode('stacked')
const modes: ('stacked' | 'exploded' | 'solo')[] = ['stacked', 'exploded', 'solo']
const nextIndex = (modes.indexOf(levelMode as any) + 1) % modes.length
useViewer.getState().setLevelMode(modes[nextIndex] ?? 'stacked')
}}
size="icon"
variant="ghost"
>
{levelMode === 'solo' && <Diamond className="h-6 w-6" />}
{levelMode === 'exploded' && <Layers2 className="h-6 w-6" />}
{(levelMode === 'stacked' || levelMode === 'manual') && <Layers className="h-6 w-6" />}
</ActionButton>
{/* Wall Mode */}
<ActionButton
label={`Walls: ${wallModeConfig[wallMode as keyof typeof wallModeConfig].label}`}
tooltipSide="top"
className={wallMode !== 'cutaway' ? 'bg-white/10' : 'opacity-60 grayscale hover:opacity-100 hover:grayscale-0 hover:bg-white/5'}
onClick={() => {
const modes: ('cutaway' | 'up' | 'down')[] = ['cutaway', 'up', 'down']
const nextIndex = (modes.indexOf(wallMode as any) + 1) % modes.length
useViewer.getState().setWallMode(modes[nextIndex] ?? 'cutaway')
}}
size="icon"
variant="ghost"
>
{(() => {
const Icon = wallModeConfig[wallMode as keyof typeof wallModeConfig].icon
return <Icon className="h-[28px] w-[28px]" />
})()}
</ActionButton>
<div className="mx-1 h-5 w-px bg-border/40" />
{/* Camera Actions */}
<ActionButton
label="Orbit Left"
tooltipSide="top"
className="group hover:bg-white/5 hidden sm:inline-flex"
onClick={() => emitter.emit('camera-controls:orbit-ccw')}
size="icon"
variant="ghost"
>
<img alt="Orbit Left" className="h-[28px] w-[28px] object-contain opacity-70 transition-opacity group-hover:opacity-100 -scale-x-100" src="/icons/rotate.png" />
</ActionButton>
<ActionButton
label="Orbit Right"
tooltipSide="top"
className="group hover:bg-white/5 hidden sm:inline-flex"
onClick={() => emitter.emit('camera-controls:orbit-cw')}
size="icon"
variant="ghost"
>
<img alt="Orbit Right" className="h-[28px] w-[28px] object-contain opacity-70 transition-opacity group-hover:opacity-100" src="/icons/rotate.png" />
</ActionButton>
<ActionButton
label="Top View"
tooltipSide="top"
className="group hover:bg-white/5"
onClick={() => emitter.emit('camera-controls:top-view')}
size="icon"
variant="ghost"
>
<img alt="Top View" className="h-[28px] w-[28px] object-contain opacity-70 transition-opacity group-hover:opacity-100" src="/icons/topview.png" />
</ActionButton>
</div>
</TooltipProvider>
</div>
</>
)
}
@@ -1,37 +0,0 @@
'use client'
import { sceneRegistry, useScene, type ZoneNode } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useFrame } from '@react-three/fiber'
export const ViewerZoneSystem = () => {
useFrame(() => {
const { levelId, zoneId } = useViewer.getState().selection
const nodes = useScene.getState().nodes
sceneRegistry.byType.zone.forEach((id) => {
const obj = sceneRegistry.nodes.get(id)
if (!obj) return
const zone = nodes[id as ZoneNode['id']] as ZoneNode | undefined
if (!zone) return
// Hide zones if:
// 1. No level is selected
// 2. Zone is not on the selected level
// 3. A zone is already selected (hide all zones to show zone contents)
const isOnSelectedLevel = zone.parentId === levelId
const shouldShow = !!levelId && isOnSelectedLevel && !zoneId
obj.visible = shouldShow
const targetOpacity = shouldShow ? '1' : '0'
const labelEl = document.getElementById(`${id}-label`)
if (labelEl && labelEl.style.opacity !== targetOpacity) {
labelEl.style.opacity = targetOpacity
}
})
})
return null
}
@@ -1,322 +0,0 @@
'use client'
import { type CameraControlEvent, emitter, sceneRegistry, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { CameraControls, CameraControlsImpl } from '@react-three/drei'
import { useThree } from '@react-three/fiber'
import { useCallback, useEffect, useMemo, useRef } from 'react'
import { Box3, Vector3 } from 'three'
import { EDITOR_LAYER } from '@/lib/constants'
import useEditor from '@/store/use-editor'
const currentTarget = new Vector3()
const tempBox = new Box3()
const tempCenter = new Vector3()
const tempSize = new Vector3()
export const CustomCameraControls = () => {
const controls = useRef<CameraControlsImpl>(null!)
const isPreviewMode = useEditor((s) => s.isPreviewMode)
const selection = useViewer((s) => s.selection)
const currentLevelId = selection.levelId
const firstLoad = useRef(true)
const camera = useThree((state) => state.camera)
const raycaster = useThree((state) => state.raycaster)
useEffect(() => {
camera.layers.enable(EDITOR_LAYER)
raycaster.layers.enable(EDITOR_LAYER)
raycaster.layers.enable(2)
}, [camera, raycaster])
useEffect(() => {
if (isPreviewMode) return // Preview mode uses auto-navigate instead
let targetY = 0
if (currentLevelId) {
const levelMesh = sceneRegistry.nodes.get(currentLevelId)
if (levelMesh) {
targetY = levelMesh.position.y
}
}
if (firstLoad.current) {
firstLoad.current = false
;(controls.current as CameraControlsImpl).setLookAt(20, 20, 20, 0, 0, 0, true)
}
;(controls.current as CameraControlsImpl).getTarget(currentTarget)
;(controls.current as CameraControlsImpl).moveTo(
currentTarget.x,
targetY,
currentTarget.z,
true,
)
}, [currentLevelId, isPreviewMode])
// Configure mouse buttons based on control mode and camera mode
const cameraMode = useViewer((state) => state.cameraMode)
const mouseButtons = useMemo(() => {
// Use ZOOM for orthographic camera, DOLLY for perspective camera
const wheelAction =
cameraMode === 'orthographic'
? CameraControlsImpl.ACTION.ZOOM
: CameraControlsImpl.ACTION.DOLLY
return {
left: isPreviewMode
? CameraControlsImpl.ACTION.SCREEN_PAN
: CameraControlsImpl.ACTION.NONE,
middle: CameraControlsImpl.ACTION.SCREEN_PAN,
right: CameraControlsImpl.ACTION.ROTATE,
wheel: wheelAction,
}
}, [cameraMode, isPreviewMode])
useEffect(() => {
const keyState = {
shiftRight: false,
shiftLeft: false,
controlRight: false,
controlLeft: false,
space: false,
}
const updateConfig = () => {
if (!controls.current) return
const shift = keyState.shiftRight || keyState.shiftLeft
const control = keyState.controlRight || keyState.controlLeft
const space = keyState.space
const wheelAction =
cameraMode === 'orthographic'
? CameraControlsImpl.ACTION.ZOOM
: CameraControlsImpl.ACTION.DOLLY
controls.current.mouseButtons.wheel = wheelAction
controls.current.mouseButtons.middle = CameraControlsImpl.ACTION.SCREEN_PAN
controls.current.mouseButtons.right = CameraControlsImpl.ACTION.ROTATE
if (isPreviewMode) {
// In preview mode, left-click is always pan (viewer-style)
controls.current.mouseButtons.left = CameraControlsImpl.ACTION.SCREEN_PAN
} else if (space) {
controls.current.mouseButtons.left = CameraControlsImpl.ACTION.SCREEN_PAN
} else {
controls.current.mouseButtons.left = CameraControlsImpl.ACTION.NONE
}
}
const onKeyDown = (event: KeyboardEvent) => {
if (event.code === 'Space') {
keyState.space = true
document.body.style.cursor = 'grab'
}
if (event.code === 'ShiftRight') {
keyState.shiftRight = true
}
if (event.code === 'ShiftLeft') {
keyState.shiftLeft = true
}
if (event.code === 'ControlRight') {
keyState.controlRight = true
}
if (event.code === 'ControlLeft') {
keyState.controlLeft = true
}
updateConfig()
}
const onKeyUp = (event: KeyboardEvent) => {
if (event.code === 'Space') {
keyState.space = false
document.body.style.cursor = ''
}
if (event.code === 'ShiftRight') {
keyState.shiftRight = false
}
if (event.code === 'ShiftLeft') {
keyState.shiftLeft = false
}
if (event.code === 'ControlRight') {
keyState.controlRight = false
}
if (event.code === 'ControlLeft') {
keyState.controlLeft = false
}
updateConfig()
}
document.addEventListener('keydown', onKeyDown)
document.addEventListener('keyup', onKeyUp)
updateConfig()
return () => {
document.removeEventListener('keydown', onKeyDown)
document.removeEventListener('keyup', onKeyUp)
}
}, [cameraMode, isPreviewMode])
// Preview mode: auto-navigate camera to selected node (viewer behavior)
const previewTargetNodeId = isPreviewMode
? (selection.zoneId ?? selection.levelId ?? selection.buildingId)
: null
useEffect(() => {
if (!isPreviewMode || !controls.current) return
const nodes = useScene.getState().nodes
let node = previewTargetNodeId ? nodes[previewTargetNodeId] : null
if (!previewTargetNodeId) {
const site = Object.values(nodes).find((n) => n.type === 'site')
node = site || null
}
if (!node) return
// Check if node has a saved camera
if (node.camera) {
const { position, target } = node.camera
requestAnimationFrame(() => {
if (!controls.current) return
controls.current.setLookAt(
position[0], position[1], position[2],
target[0], target[1], target[2],
true,
)
})
return
}
if (!previewTargetNodeId) return
// Calculate camera position from bounding box
const object3D = sceneRegistry.nodes.get(previewTargetNodeId)
if (!object3D) return
tempBox.setFromObject(object3D)
tempBox.getCenter(tempCenter)
tempBox.getSize(tempSize)
const maxDim = Math.max(tempSize.x, tempSize.y, tempSize.z)
const distance = Math.max(maxDim * 2, 15)
controls.current.setLookAt(
tempCenter.x + distance * 0.7,
tempCenter.y + distance * 0.5,
tempCenter.z + distance * 0.7,
tempCenter.x,
tempCenter.y,
tempCenter.z,
true,
)
}, [isPreviewMode, previewTargetNodeId])
useEffect(() => {
const handleNodeCapture = ({ nodeId }: CameraControlEvent) => {
if (!controls.current) return
const position = new Vector3()
const target = new Vector3()
controls.current.getPosition(position)
controls.current.getTarget(target)
const state = useScene.getState()
state.updateNode(nodeId, {
camera: {
position: [position.x, position.y, position.z],
target: [target.x, target.y, target.z],
mode: useViewer.getState().cameraMode,
},
})
}
const handleNodeView = ({ nodeId }: CameraControlEvent) => {
if (!controls.current) return
const node = useScene.getState().nodes[nodeId]
if (!node || !node.camera) return
const { position, target } = node.camera
controls.current.setLookAt(
position[0],
position[1],
position[2],
target[0],
target[1],
target[2],
true,
)
}
const handleTopView = () => {
if (!controls.current) return
const currentPolarAngle = controls.current.polarAngle
// Toggle: if already near top view (< 0.1 radians ≈ 5.7°), go back to 45°
// Otherwise, go to top view (0°)
const targetAngle = currentPolarAngle < 0.1 ? Math.PI / 4 : 0
controls.current.rotatePolarTo(targetAngle, true)
}
const handleOrbitCW = () => {
if (!controls.current) return
const currentAzimuth = controls.current.azimuthAngle
const currentPolar = controls.current.polarAngle
// Round to nearest 90° increment, then rotate 90° clockwise
const rounded = Math.round(currentAzimuth / (Math.PI / 2)) * (Math.PI / 2)
const target = rounded - Math.PI / 2
controls.current.rotateTo(target, currentPolar, true)
}
const handleOrbitCCW = () => {
if (!controls.current) return
const currentAzimuth = controls.current.azimuthAngle
const currentPolar = controls.current.polarAngle
// Round to nearest 90° increment, then rotate 90° counter-clockwise
const rounded = Math.round(currentAzimuth / (Math.PI / 2)) * (Math.PI / 2)
const target = rounded + Math.PI / 2
controls.current.rotateTo(target, currentPolar, true)
}
emitter.on('camera-controls:capture', handleNodeCapture)
emitter.on('camera-controls:view', handleNodeView)
emitter.on('camera-controls:top-view', handleTopView)
emitter.on('camera-controls:orbit-cw', handleOrbitCW)
emitter.on('camera-controls:orbit-ccw', handleOrbitCCW)
return () => {
emitter.off('camera-controls:capture', handleNodeCapture)
emitter.off('camera-controls:view', handleNodeView)
emitter.off('camera-controls:top-view', handleTopView)
emitter.off('camera-controls:orbit-cw', handleOrbitCW)
emitter.off('camera-controls:orbit-ccw', handleOrbitCCW)
}
}, [])
const onTransitionStart = useCallback(() => {
useViewer.getState().setCameraDragging(true)
}, [])
const onRest = useCallback(() => {
useViewer.getState().setCameraDragging(false)
}, [])
return (
<CameraControls
makeDefault
maxDistance={100}
maxPolarAngle={Math.PI / 2 - 0.1}
minDistance={10}
minPolarAngle={0}
ref={controls}
mouseButtons={mouseButtons}
onTransitionStart={onTransitionStart}
onRest={onRest}
onSleep={onRest}
restThreshold={0.01}
/>
)
}
@@ -1,54 +0,0 @@
'use client'
import { useViewer } from '@pascal-app/viewer'
import { useThree } from '@react-three/fiber'
import { useEffect } from 'react'
import { GLTFExporter } from 'three/examples/jsm/exporters/GLTFExporter.js'
export function ExportManager() {
const scene = useThree((state) => state.scene)
const setExportScene = useViewer((state) => state.setExportScene)
useEffect(() => {
const exportFn = async () => {
// Find the scene renderer group by name
const sceneGroup = scene.getObjectByName('scene-renderer')
if (!sceneGroup) {
console.error('scene-renderer group not found')
return
}
const exporter = new GLTFExporter()
const date = new Date().toISOString().split('T')[0]
return new Promise<void>((resolve, reject) => {
exporter.parse(
sceneGroup,
(gltf) => {
const blob = new Blob([gltf as ArrayBuffer], { type: 'model/gltf-binary' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `model_${date}.glb`
link.click()
URL.revokeObjectURL(url)
resolve()
},
(error) => {
console.error('Export error:', error)
reject(error)
},
{ binary: true }
)
})
}
setExportScene(exportFn)
return () => {
setExportScene(null)
}
}, [scene, setExportScene])
return null
}
@@ -1,157 +0,0 @@
'use client'
import {
type AnyNode,
type AnyNodeId,
DoorNode,
ItemNode,
sceneRegistry,
useScene,
WindowNode,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { Html } from '@react-three/drei'
import { useFrame } from '@react-three/fiber'
import { Copy, Move, Trash2 } from 'lucide-react'
import { useCallback, useRef } from 'react'
import * as THREE from 'three'
import { sfxEmitter } from '@/lib/sfx-bus'
import useEditor from '@/store/use-editor'
const ALLOWED_TYPES = ['item', 'door', 'window']
export function FloatingActionMenu() {
const selectedIds = useViewer((s) => s.selection.selectedIds)
const nodes = useScene((s) => s.nodes)
const deleteNode = useScene((s) => s.deleteNode)
const setMovingNode = useEditor((s) => s.setMovingNode)
const setSelection = useViewer((s) => s.setSelection)
const groupRef = useRef<THREE.Group>(null)
// Only show for single selection of specific types
const selectedId = selectedIds.length === 1 ? selectedIds[0] : null
const node = selectedId ? nodes[selectedId as AnyNodeId] : null
const isValidType = node ? ALLOWED_TYPES.includes(node.type) : false
useFrame(() => {
if (!selectedId || !isValidType || !groupRef.current) return
const obj = sceneRegistry.nodes.get(selectedId)
if (obj) {
// Calculate bounding box in world space
const box = new THREE.Box3().setFromObject(obj)
if (!box.isEmpty()) {
const center = box.getCenter(new THREE.Vector3())
// Position slightly above the object
groupRef.current.position.set(center.x, box.max.y + 0.3, center.z)
}
}
})
const handleMove = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation()
if (!node) return
sfxEmitter.emit('sfx:item-pick')
if (node.type === 'item' || node.type === 'window' || node.type === 'door') {
setMovingNode(node as any)
}
setSelection({ selectedIds: [] })
},
[node, setMovingNode, setSelection],
)
const handleDuplicate = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation()
if (!node || !node.parentId) return
sfxEmitter.emit('sfx:item-pick')
useScene.temporal.getState().pause()
let duplicateInfo = structuredClone(node) as any
delete duplicateInfo.id
duplicateInfo.metadata = { ...duplicateInfo.metadata, isNew: true }
let duplicate: AnyNode | null = null
try {
if (node.type === 'door') {
duplicate = DoorNode.parse(duplicateInfo)
} else if (node.type === 'window') {
duplicate = WindowNode.parse(duplicateInfo)
} else if (node.type === 'item') {
duplicate = ItemNode.parse(duplicateInfo)
}
} catch (error) {
console.error('Failed to parse duplicate', error)
return
}
if (duplicate) {
if (duplicate.type === 'door' || duplicate.type === 'window') {
useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)
}
if (duplicate.type === 'item' || duplicate.type === 'window' || duplicate.type === 'door') {
setMovingNode(duplicate as any)
}
setSelection({ selectedIds: [] })
}
},
[node, setMovingNode, setSelection],
)
const handleDelete = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation()
if (!selectedId || !node) return
sfxEmitter.emit('sfx:item-delete')
deleteNode(selectedId as AnyNodeId)
if (node.parentId) useScene.getState().dirtyNodes.add(node.parentId as AnyNodeId)
setSelection({ selectedIds: [] })
},
[selectedId, node, deleteNode, setSelection],
)
if (!selectedId || !node || !isValidType) return null
return (
<group ref={groupRef}>
<Html
center
zIndexRange={[100, 0]}
style={{
pointerEvents: 'auto',
touchAction: 'none',
}}
>
<div
className="flex items-center gap-1 p-1 rounded-lg border border-border bg-background/95 shadow-xl backdrop-blur-md"
onPointerDown={(e) => e.stopPropagation()}
onPointerUp={(e) => e.stopPropagation()}
>
<button
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" />
</button>
<button
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" />
</button>
<button
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" />
</button>
</div>
</Html>
</group>
)
}
-155
View File
@@ -1,155 +0,0 @@
'use client'
import { emitter, type GridEvent, sceneRegistry } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useFrame } from '@react-three/fiber'
import { useEffect, useMemo, useRef, useState } from 'react'
import { MathUtils, type Mesh, Vector2 } from 'three'
import { color, float, fract, fwidth, mix, positionLocal, uniform } from 'three/tsl'
import { MeshBasicNodeMaterial } from 'three/webgpu'
import { useGridEvents } from '@/hooks/use-grid-events'
import { EDITOR_LAYER } from '@/lib/constants'
export const Grid = ({
cellSize = 0.5,
cellThickness = 0.5,
cellColor = '#888888',
sectionSize = 1,
sectionThickness = 1,
sectionColor = '#000000',
fadeDistance = 100,
fadeStrength = 1,
revealRadius = 10,
}: {
cellSize?: number
cellThickness?: number
cellColor?: string
sectionSize?: number
sectionThickness?: number
sectionColor?: string
fadeDistance?: number
fadeStrength?: number
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(() => {
// Use xy since plane geometry is in XY space (before rotation)
const pos = positionLocal.xy
// Cursor position uniform
const cursorPos = uniform(cursorPositionRef.current)
// Grid line function using fwidth for anti-aliasing
// Returns 1 on grid lines, 0 elsewhere
const getGrid = (size: number, thickness: number) => {
const r = pos.div(size)
const fw = fwidth(r)
// Distance to nearest grid line for each axis
const grid = fract(r.sub(0.5)).sub(0.5).abs()
// Anti-aliased step: divide by fwidth and clamp
const lineX = float(1).sub(
grid.x
.div(fw.x)
.add(1 - thickness)
.min(1),
)
const lineY = float(1).sub(
grid.y
.div(fw.y)
.add(1 - thickness)
.min(1),
)
// Combine both axes - max gives us lines in both directions
return lineX.max(lineY)
}
const g1 = getGrid(cellSize, cellThickness)
const g2 = getGrid(sectionSize, sectionThickness)
// Distance fade from center
const dist = pos.length()
const fade = float(1).sub(dist.div(fadeDistance).min(1)).pow(fadeStrength)
// Cursor reveal effect - distance from cursor
const cursorDist = pos.sub(cursorPos).length()
const cursorFade = float(1).sub(cursorDist.div(revealRadius).clamp(0, 1)).smoothstep(0, 1)
// Mix colors based on section grid
const gridColor = mix(
color(effectiveCellColor),
color(effectiveSectionColor),
float(sectionThickness).mul(g2).min(1),
)
// 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)
return new MeshBasicNodeMaterial({
transparent: true,
colorNode: gridColor,
opacityNode: finalAlpha,
depthWrite: false,
})
}, [
cellSize,
cellThickness,
effectiveCellColor,
sectionSize,
sectionThickness,
effectiveSectionColor,
fadeDistance,
fadeStrength,
revealRadius
])
const gridRef = useRef<Mesh>(null!)
const [gridY, setGridY] = useState(0)
// Use custom raycasting for grid events (independent of mesh events)
useGridEvents(gridY)
// Update cursor position from grid:move events
useEffect(() => {
const onGridMove = (event: GridEvent) => {
cursorPositionRef.current.set(event.position[0], -event.position[2])
}
emitter.on('grid:move', onGridMove)
return () => {
emitter.off('grid:move', onGridMove)
}
}, [])
useFrame((_, delta) => {
const currentLevelId = useViewer.getState().selection.levelId
let targetY = 0
if (currentLevelId) {
const levelMesh = sceneRegistry.nodes.get(currentLevelId)
if (levelMesh) {
targetY = levelMesh.position.y
}
}
const newY = MathUtils.lerp(gridRef.current.position.y, targetY, 12 * delta)
gridRef.current.position.y = newY
setGridY(newY)
})
const showGrid = useViewer((state) => state.showGrid)
return (
<mesh rotation-x={-Math.PI / 2} material={material} ref={gridRef} visible={showGrid} layers={EDITOR_LAYER}>
<planeGeometry args={[fadeDistance * 2, fadeDistance * 2]} />
</mesh>
)
}
-182
View File
@@ -1,182 +0,0 @@
'use client'
import { initSpaceDetectionSync, initSpatialGridSync, useScene } from '@pascal-app/core'
import { InteractiveSystem, useViewer, Viewer } from '@pascal-app/viewer'
import { useEffect } from 'react'
import { useProjectScene } from '@/features/community/lib/models/hooks'
import { useProjectStore } from '@/features/community/lib/projects/store'
import { useKeyboard } from '@/hooks/use-keyboard'
import { initSFXBus } from '@/lib/sfx-bus'
import useEditor from '@/store/use-editor'
import { ViewerOverlay } from '@/app/viewer/[id]/viewer-overlay'
import { ViewerZoneSystem } from '@/app/viewer/[id]/viewer-zone-system'
import { FeedbackDialog } from '../feedback-dialog'
import { PascalRadio } from '../pascal-radio'
import { PreviewButton } from '../preview-button'
import { CeilingSystem } from '../systems/ceiling/ceiling-system'
import { ZoneLabelEditorSystem } from '../systems/zone/zone-label-editor-system'
import { ZoneSystem } from '../systems/zone/zone-system'
import { ToolManager } from '../tools/tool-manager'
import { ActionMenu } from '../ui/action-menu'
import { HelperManager } from '../ui/helpers/helper-manager'
import { PanelManager } from '../ui/panels/panel-manager'
import { ErrorBoundary } from '../ui/primitives/error-boundary'
import { SidebarProvider } from '../ui/primitives/sidebar'
import { SceneLoader } from '../ui/scene-loader'
import { AppSidebar } from '../ui/sidebar/app-sidebar'
import { CustomCameraControls } from './custom-camera-controls'
import { ExportManager } from './export-manager'
import { FloatingActionMenu } from './floating-action-menu'
import { Grid } from './grid'
import { PresetThumbnailGenerator } from './preset-thumbnail-generator'
import { SelectionManager } from './selection-manager'
import { SiteEdgeLabels } from './site-edge-labels'
import { ThumbnailGenerator } from './thumbnail-generator'
// Load default scene initially (will be replaced when project loads)
useScene.getState().loadScene()
initSpatialGridSync()
initSpaceDetectionSync(useScene, useEditor)
// Auto-select the first building and level for the default scene
const sceneNodes = useScene.getState().nodes as Record<string, any>
const sceneRootIds = useScene.getState().rootNodeIds
const siteNode = sceneRootIds[0] ? sceneNodes[sceneRootIds[0]] : null
const resolve = (child: any) => (typeof child === 'string' ? sceneNodes[child] : child)
const firstBuilding = siteNode?.children?.map(resolve).find((n: any) => n?.type === 'building')
const firstLevel = firstBuilding?.children?.map(resolve).find((n: any) => n?.type === 'level')
if (firstBuilding && firstLevel) {
useViewer.getState().setSelection({
buildingId: firstBuilding.id,
levelId: firstLevel.id,
selectedIds: [],
zoneId: null,
})
useEditor.getState().setPhase('structure')
useEditor.getState().setStructureLayer('elements')
// Auto-select the wall tool if the level is empty
if (!firstLevel.children || firstLevel.children.length === 0) {
useEditor.getState().setMode('build')
useEditor.getState().setTool('wall')
}
}
// Initialize SFX bus to connect events to sound effects
initSFXBus()
interface EditorProps {
projectId?: string
}
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">
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"
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"
href="/"
>
Back to home
</a>
</div>
</div>
</div>
)
}
export default function Editor({ projectId }: EditorProps) {
useKeyboard()
useProjectScene()
const isProjectLoading = useProjectStore((state) => state.isLoading)
const isSceneLoading = useProjectStore((state) => state.isSceneLoading)
const isLoading = isProjectLoading || isSceneLoading
const isPreviewMode = useEditor((s) => s.isPreviewMode)
const activeProject = useProjectStore((s) => s.activeProject)
useEffect(() => {
if (projectId) {
useViewer.getState().setProjectId(projectId)
} else {
useViewer.getState().setProjectId(null)
}
}, [projectId])
useEffect(() => {
document.body.classList.add('dark')
return () => {
document.body.classList.remove('dark')
}
}, [])
return (
<div className="w-full h-full dark text-foreground">
{isLoading && <SceneLoader />}
{isPreviewMode ? (
<ViewerOverlay
projectName={activeProject?.name}
onBack={() => useEditor.getState().setPreviewMode(false)}
/>
) : (
<>
<ActionMenu />
<PanelManager />
<HelperManager />
{/* Top-right controls */}
<div className="pointer-events-none fixed top-4 right-4 z-50 flex items-start gap-2">
<div className="pointer-events-auto">
<PreviewButton />
</div>
<div className="pointer-events-auto">
<PascalRadio />
</div>
<div className="pointer-events-auto">
<FeedbackDialog projectId={projectId} />
</div>
</div>
<SidebarProvider className="fixed z-20">
<AppSidebar />
</SidebarProvider>
</>
)}
<ErrorBoundary key={projectId} fallback={<EditorSceneCrashFallback />}>
<Viewer selectionManager={isPreviewMode ? 'default' : 'custom'}>
{!isPreviewMode && <SelectionManager />}
{!isPreviewMode && <FloatingActionMenu />}
<ExportManager />
{/* Swap zone systems: viewer drill-down vs editor layer toggle */}
{isPreviewMode ? <ViewerZoneSystem /> : <ZoneSystem />}
<CeilingSystem />
{!isPreviewMode && (
<Grid cellColor="#aaa" sectionColor="#ccc" fadeDistance={500} />
)}
{!isPreviewMode && <ToolManager />}
<CustomCameraControls />
<ThumbnailGenerator projectId={projectId} />
<PresetThumbnailGenerator />
{!isPreviewMode && <SiteEdgeLabels />}
{isPreviewMode && <InteractiveSystem />}
</Viewer>
{!isPreviewMode && <ZoneLabelEditorSystem />}
</ErrorBoundary>
</div>
)
}
@@ -1,123 +0,0 @@
'use client'
import { emitter, sceneRegistry } from '@pascal-app/core'
import { useThree } from '@react-three/fiber'
import { useCallback, useEffect } from 'react'
import * as THREE from 'three'
const THUMBNAIL_SIZE = 1080
const CAMERA_FOV = 45
export const PresetThumbnailGenerator = () => {
const gl = useThree((state) => state.gl)
const scene = useThree((state) => state.scene)
const generate = useCallback(
async ({ presetId, nodeId }: { presetId: string; nodeId: string }) => {
const target = sceneRegistry.nodes.get(nodeId)
if (!target) {
console.error('❌ PresetThumbnail: node not found', nodeId)
return
}
// Compute each mesh's transform relative to the target node (cancels world
// position/rotation), so the item is always rendered at origin with a known
// neutral orientation regardless of where it's placed in the scene.
target.updateWorldMatrix(true, true)
const targetInverse = new THREE.Matrix4().copy(target.matrixWorld).invert()
const relMatrix = new THREE.Matrix4()
const clones: THREE.Object3D[] = []
target.traverse((obj) => {
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)
scene.add(c)
clones.push(c)
})
if (clones.length === 0) {
console.error('❌ PresetThumbnail: no renderable objects found', nodeId)
return
}
// Combined bounding box across all clones
const box = new THREE.Box3()
for (const c of clones) box.expandByObject(c)
if (box.isEmpty()) {
for (const c of clones) scene.remove(c)
console.error('❌ PresetThumbnail: empty bounding box', nodeId)
return
}
const sphere = new THREE.Sphere()
box.getBoundingSphere(sphere)
// Camera: aspect matches canvas (center-cropped to square after render)
const { width, height } = gl.domElement
const camera = new THREE.PerspectiveCamera(CAMERA_FOV, width / height, 0.01, 1000)
const dir = new THREE.Vector3(-0.5, 0.5, 0.5).normalize()
const fovRad = (CAMERA_FOV * Math.PI) / 180
const dist = (sphere.radius / Math.tan(fovRad / 2)) * 1.3
camera.position.copy(sphere.center).addScaledVector(dir, dist)
camera.lookAt(sphere.center)
camera.updateProjectionMatrix()
// Hide all scene geometry except the clones — leave lights, cameras, etc. intact
const cloneSet = new Set<THREE.Object3D>(clones)
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
snapshot.set(obj, obj.visible)
obj.visible = false
})
gl.render(scene, camera)
// Restore visibility and remove clones
snapshot.forEach((wasVisible, obj) => {
obj.visible = wasVisible
})
for (const c of clones) scene.remove(c)
// Center-crop to square and scale to THUMBNAIL_SIZE
const minDim = Math.min(width, height)
const sx = Math.round((width - minDim) / 2)
const sy = Math.round((height - minDim) / 2)
const offscreen = document.createElement('canvas')
offscreen.width = THUMBNAIL_SIZE
offscreen.height = THUMBNAIL_SIZE
const ctx = offscreen.getContext('2d')!
ctx.drawImage(gl.domElement, sx, sy, minDim, minDim, 0, 0, THUMBNAIL_SIZE, THUMBNAIL_SIZE)
offscreen.toBlob(async (blob) => {
if (!blob) {
console.error('❌ PresetThumbnail: failed to create blob')
return
}
const res = await fetch(`/api/presets/${presetId}/thumbnail`, {
method: 'POST',
body: blob,
headers: { 'Content-Type': 'image/png' },
})
if (res.ok) {
const json = await res.json()
emitter.emit('preset:thumbnail-updated', { presetId, thumbnailUrl: json.thumbnail_url })
} else {
console.error('❌ PresetThumbnail: upload failed', await res.text())
}
}, 'image/png')
},
[gl, scene],
)
useEffect(() => {
emitter.on('preset:generate-thumbnail', generate)
return () => emitter.off('preset:generate-thumbnail', generate)
}, [generate])
return null
}
@@ -1,449 +0,0 @@
import {
type AnyNode,
type BuildingNode,
emitter,
type ItemNode,
type NodeEvent,
resolveLevelId,
sceneRegistry,
useScene,
} from "@pascal-app/core";
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;
}
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;
};
const computeNextIds = (
node: AnyNode,
selectedIds: string[],
event?: any,
modifierKeys?: ModifierKeys
): string[] => {
const isMeta = event?.metaKey || event?.nativeEvent?.metaKey || modifierKeys?.meta || false;
const isCtrl = event?.ctrlKey || event?.nativeEvent?.ctrlKey || modifierKeys?.ctrl || false;
console.log("computeNextIds:", {
nodeId: node.id,
selectedIds,
isMeta,
isCtrl,
eventMeta: event?.metaKey,
nativeMeta: event?.nativeEvent?.metaKey,
modMeta: modifierKeys?.meta
});
if (isMeta || isCtrl) {
if (selectedIds.includes(node.id)) {
return selectedIds.filter((id) => id !== node.id);
} else {
return [...selectedIds, node.id];
}
}
// Not holding modifiers: select only this node
return [node.id];
};
const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
site: {
types: ["building"],
handleSelect: (node) => {
useViewer
.getState()
.setSelection({ buildingId: (node as BuildingNode).id });
},
handleDeselect: () => {
useViewer.getState().setSelection({ buildingId: null });
},
isValid: (node) => node.type === "building",
},
structure: {
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 updates: any = {};
if (nodeLevelId !== "default" && nodeLevelId !== selection.levelId) {
updates.levelId = nodeLevelId;
}
if (buildingId && buildingId !== selection.buildingId) {
updates.buildingId = buildingId;
}
if (node.type === 'zone') {
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);
} else {
updates.selectedIds = computeNextIds(node, selection.selectedIds, nativeEvent, modifierKeys);
setSelection(updates);
}
},
handleDeselect: () => {
const structureLayer = useEditor.getState().structureLayer;
if (structureLayer === "zones") {
useViewer.getState().setSelection({ zoneId: null });
} else {
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;
}
},
},
furnish: {
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 updates: any = {};
if (nodeLevelId !== "default" && nodeLevelId !== selection.levelId) {
updates.levelId = nodeLevelId;
}
if (buildingId && buildingId !== selection.buildingId) {
updates.buildingId = buildingId;
}
updates.selectedIds = computeNextIds(node, selection.selectedIds, nativeEvent, modifierKeys);
setSelection(updates);
},
handleDeselect: () => {
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";
},
},
};
export const SelectionManager = () => {
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 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;
};
const onKeyUp = (event: KeyboardEvent) => {
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;
};
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);
};
}, []);
useEffect(() => {
if (mode !== "select") return;
if (movingNode) return;
const onClick = (event: NodeEvent) => {
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 (isNodeInCurrentLevel(node)) {
if (
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";
} else {
targetPhase = "furnish";
}
}
if (targetPhase !== currentPhase) {
useEditor.getState().setPhase(targetPhase);
if (targetPhase === "structure" && useEditor.getState().structureLayer === "zones") {
useEditor.getState().setStructureLayer("elements");
}
currentPhase = targetPhase;
}
}
}
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);
// Reset the handled flag after a short delay to allow grid:click to be ignored
setTimeout(() => {
clickHandledRef.current = false;
}, 50);
}
};
const allTypes = ["wall", "item", "building", "zone", "slab", "ceiling", "roof", "window", "door"];
allTypes.forEach((type) => {
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);
return () => {
allTypes.forEach((type) => {
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;
const onEnter = (event: NodeEvent) => {
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;
}
}
// Ignore zones unless specifically in zones layer
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;
}
event.stopPropagation();
useViewer.setState({ hoveredId: node.id });
};
const onLeave = (event: NodeEvent) => {
if (useViewer.getState().hoveredId === event.node.id) {
useViewer.setState({ hoveredId: null });
}
};
const onDoubleClick = (event: NodeEvent) => {
const node = event.node;
const currentPhase = useEditor.getState().phase;
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";
}
} else if (
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";
} else {
targetPhase = "furnish";
}
}
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");
}
const strategy = SELECTION_STRATEGIES[targetPhase];
if (strategy) {
strategy.handleSelect(node, event.nativeEvent, modifierKeysRef.current);
}
}
};
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);
});
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]);
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);
useEffect(() => {
let idsToHighlight: string[] = [];
// 1. Determine what should be highlighted based on Phase
switch (phase) {
case "site":
// Only highlight the building if one is selected
if (selection.buildingId) idsToHighlight = [selection.buildingId];
break;
case "structure":
// Highlight selected items (walls/slabs)
// We IGNORE buildingId even if it's set in the store
idsToHighlight = selection.selectedIds;
break;
case "furnish":
// Highlight selected furniture/items
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];
}
// 2. Sync with the imperative outliner arrays (mutate in place to keep references)
outliner.selectedObjects.length = 0;
for (const id of idsToHighlight) {
const obj = sceneRegistry.nodes.get(id);
if (obj) outliner.selectedObjects.push(obj);
}
outliner.hoveredObjects.length = 0;
if (hoveredId) {
const obj = sceneRegistry.nodes.get(hoveredId);
if (obj) outliner.hoveredObjects.push(obj);
}
}, [phase, selection, hoveredId, outliner]);
return null;
};
@@ -1,66 +0,0 @@
'use client'
import { sceneRegistry, useScene } from '@pascal-app/core'
import type { SiteNode } from '@pascal-app/core'
import { Html } from '@react-three/drei'
import { createPortal, useFrame } from '@react-three/fiber'
import { useMemo, useRef, useState } from 'react'
import type { Object3D } from 'three'
export function SiteEdgeLabels() {
const rootNodeIds = useScene((state) => state.rootNodeIds)
const nodes = useScene((state) => state.nodes)
const siteNode = rootNodeIds[0] ? (nodes[rootNodeIds[0]] as SiteNode) : null
const siteNodeId = siteNode?.id
const [siteObj, setSiteObj] = useState<Object3D | null>(null)
const prevSiteNodeIdRef = useRef<string | undefined>(undefined)
// Poll each frame until the site group is registered.
// Also resets when the site node ID changes (new project loaded).
useFrame(() => {
if (siteNodeId !== prevSiteNodeIdRef.current) {
prevSiteNodeIdRef.current = siteNodeId
setSiteObj(null)
return
}
if (siteObj || !siteNodeId) return
const obj = sceneRegistry.nodes.get(siteNodeId)
if (obj) setSiteObj(obj)
})
const edges = useMemo(() => {
const polygon = siteNode?.polygon?.points ?? []
if (polygon.length < 2) return []
return polygon.map(([x1, z1], i) => {
const [x2, z2] = polygon[(i + 1) % polygon.length]!
const midX = (x1! + x2) / 2
const midZ = (z1! + z2) / 2
const dist = Math.sqrt((x2 - x1!) ** 2 + (z2 - z1!) ** 2)
return { midX, midZ, dist }
})
}, [siteNode?.polygon?.points])
if (!siteObj || edges.length === 0) return null
return createPortal(
<>
{edges.map((edge, i) => (
<Html
center
key={`edge-${i}`}
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
</div>
</Html>
))}
</>,
siteObj,
)
}
@@ -1,179 +0,0 @@
'use client'
import { emitter, sceneRegistry, useScene } from '@pascal-app/core'
import { snapLevelsToTruePositions } from '@pascal-app/viewer'
import { useThree } from '@react-three/fiber'
import { useCallback, useEffect, useRef } from 'react'
import * as THREE from 'three'
import { uploadProjectThumbnail } from '@/features/community/lib/projects/actions'
import { useProjectStore } from '@/features/community/lib/projects/store'
import { EDITOR_LAYER } from '@/lib/constants'
const THUMBNAIL_WIDTH = 1920
const THUMBNAIL_HEIGHT = 1080
const AUTO_SAVE_DELAY = 10_000
interface ThumbnailGeneratorProps {
projectId?: string
}
export const ThumbnailGenerator = ({ projectId: propProjectId }: ThumbnailGeneratorProps) => {
const gl = useThree((state) => state.gl)
const scene = useThree((state) => state.scene)
const isGenerating = useRef(false)
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const pendingAutoRef = useRef(false)
const generate = useCallback(async (projectId: string) => {
if (isGenerating.current) {
console.log('⏸️ Thumbnail generation already in progress')
return
}
isGenerating.current = true
console.log('📸 Generating thumbnail for project:', projectId)
try {
const thumbnailCamera = new THREE.PerspectiveCamera(60, THUMBNAIL_WIDTH / THUMBNAIL_HEIGHT, 0.1, 1000)
// Check if the site node has a saved camera, otherwise use default isometric position
const nodes = useScene.getState().nodes
const siteNode = Object.values(nodes).find((n) => n.type === 'site')
if (siteNode?.camera) {
const { position, target } = siteNode.camera
thumbnailCamera.position.set(position[0], position[1], position[2])
thumbnailCamera.lookAt(target[0], target[1], target[2])
} else {
thumbnailCamera.position.set(8, 8, 8)
thumbnailCamera.lookAt(0, 0, 0)
}
thumbnailCamera.layers.disable(EDITOR_LAYER) // Render only default layer to exclude helper visuals
// Match camera aspect to current canvas so the render looks correct
const { width, height } = gl.domElement
thumbnailCamera.aspect = width / height
thumbnailCamera.updateProjectionMatrix()
// Snap levels to true stacked positions so the thumbnail always shows a clean view,
// regardless of the current levelMode (exploded, solo, etc.)
const restoreLevels = snapLevelsToTruePositions()
// Hide guides and scans — they are reference overlays, not part of the architectural model
const visibilitySnapshot = new Map<string, boolean>()
for (const type of ['scan', 'guide'] as const) {
sceneRegistry.byType[type].forEach((id) => {
const obj = sceneRegistry.nodes.get(id)
if (obj) {
visibilitySnapshot.set(id, obj.visible)
obj.visible = false
}
})
}
gl.render(scene, thumbnailCamera)
restoreLevels()
visibilitySnapshot.forEach((wasVisible, id) => {
const obj = sceneRegistry.nodes.get(id)
if (obj) obj.visible = wasVisible
})
// Center-crop the canvas to the thumbnail aspect ratio, then scale — avoids deformation
const srcAspect = width / height
const dstAspect = THUMBNAIL_WIDTH / THUMBNAIL_HEIGHT
let sx = 0, sy = 0, sWidth = width, sHeight = height
if (srcAspect > dstAspect) {
sWidth = Math.round(height * dstAspect)
sx = Math.round((width - sWidth) / 2)
} else if (srcAspect < dstAspect) {
sHeight = Math.round(width / dstAspect)
sy = Math.round((height - sHeight) / 2)
}
const offscreen = document.createElement('canvas')
offscreen.width = THUMBNAIL_WIDTH
offscreen.height = THUMBNAIL_HEIGHT
const ctx = offscreen.getContext('2d')!
ctx.drawImage(gl.domElement, sx, sy, sWidth, sHeight, 0, 0, THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT)
offscreen.toBlob(async (blob) => {
if (blob) {
console.log('☁️ Uploading thumbnail to storage...')
const result = await uploadProjectThumbnail(projectId, blob)
if (result.success) {
useProjectStore.getState().updateActiveThumbnail(result.data.thumbnail_url)
} else {
console.error('❌ Failed to upload thumbnail:', result.error)
}
} else {
console.error('❌ Failed to create blob from canvas')
}
isGenerating.current = false
}, 'image/png')
} catch (error) {
console.error('❌ Failed to generate thumbnail:', error)
isGenerating.current = false
}
}, [gl, scene])
// Manual trigger via emitter
useEffect(() => {
const handleGenerateThumbnail = async (event: { projectId: string }) => {
const projectId = propProjectId || event.projectId
if (!projectId) {
console.error('❌ No project ID provided')
return
}
await generate(projectId)
}
emitter.on('camera-controls:generate-thumbnail', handleGenerateThumbnail)
return () => emitter.off('camera-controls:generate-thumbnail', handleGenerateThumbnail)
}, [generate, propProjectId])
// Auto-trigger: debounced on scene changes, deferred if tab is hidden
useEffect(() => {
if (!propProjectId) return
const triggerNow = () => generate(propProjectId)
const scheduleOrDefer = () => {
if (document.visibilityState === 'visible') {
triggerNow()
} else {
// Tab is hidden — remember to fire when the user comes back
pendingAutoRef.current = true
}
}
const onSceneChange = () => {
if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current)
debounceTimerRef.current = setTimeout(scheduleOrDefer, AUTO_SAVE_DELAY)
}
const onVisibilityChange = () => {
if (document.visibilityState === 'visible' && pendingAutoRef.current) {
pendingAutoRef.current = false
triggerNow()
}
}
// Subscribe to node changes — any structural edit resets the timer
const unsubscribe = useScene.subscribe((state, prevState) => {
if (state.nodes !== prevState.nodes) onSceneChange()
})
document.addEventListener('visibilitychange', onVisibilityChange)
return () => {
if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current)
unsubscribe()
document.removeEventListener('visibilitychange', onVisibilityChange)
}
}, [propProjectId, generate])
return null
}
-306
View File
@@ -1,306 +0,0 @@
'use client'
import { useScene } from '@pascal-app/core'
import { ImageIcon, MessageSquare, X } from 'lucide-react'
import { useParams } from 'next/navigation'
import { useCallback, useRef, useState } from 'react'
import { Button } from '@/components/ui/primitives/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/primitives/dialog'
import { createImageUploadUrls, submitFeedback } from '@/features/community/lib/feedback/actions'
const MAX_IMAGES = 5
const MAX_IMAGE_SIZE = 5 * 1024 * 1024
type ImagePreview = { file: File; url: string }
export function FeedbackDialog({ projectId: projectIdProp }: { projectId?: string }) {
const params = useParams()
const projectId = projectIdProp ?? (params?.projectId as string | undefined)
const [open, setOpen] = useState(false)
const [message, setMessage] = useState('')
const [images, setImages] = useState<ImagePreview[]>([])
const [isDragging, setIsDragging] = useState(false)
const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
const [sent, setSent] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
const dragCounter = useRef(0)
const handleOpen = () => {
setOpen(true)
setSent(false)
setError(null)
setMessage('')
setImages([])
setIsDragging(false)
dragCounter.current = 0
}
const handleClose = () => {
if (isSubmitting) return
setOpen(false)
images.forEach((img) => {
URL.revokeObjectURL(img.url)
})
}
const addFiles = useCallback((files: FileList | File[]) => {
const incoming = Array.from(files).filter(
(f) => f.type.startsWith('image/') && f.size <= MAX_IMAGE_SIZE,
)
setImages((prev) => {
const remaining = MAX_IMAGES - prev.length
const added = incoming.slice(0, remaining).map((file) => ({
file,
url: URL.createObjectURL(file),
}))
return [...prev, ...added]
})
}, [])
const removeImage = (index: number) => {
setImages((prev) => {
const img = prev[index]
if (img) URL.revokeObjectURL(img.url)
return prev.filter((_, i) => i !== index)
})
}
// ── Drag handlers (on the entire dialog content) ──
const onDragEnter = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
dragCounter.current++
if (e.dataTransfer.types.includes('Files')) {
setIsDragging(true)
}
}
const onDragLeave = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
dragCounter.current--
if (dragCounter.current === 0) {
setIsDragging(false)
}
}
const onDragOver = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
}
const onDrop = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
dragCounter.current = 0
setIsDragging(false)
if (e.dataTransfer.files.length > 0) {
addFiles(e.dataTransfer.files)
}
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError(null)
setIsSubmitting(true)
try {
// Capture scene graph snapshot
let sceneGraph: unknown = null
try {
const { nodes, rootNodeIds } = useScene.getState()
sceneGraph = { nodes, rootNodeIds }
} catch {
// Scene store may not be available (e.g. on non-editor pages)
}
// Upload images directly to Supabase Storage via signed URLs
let imagePaths: string[] = []
if (images.length > 0) {
const urlResult = await createImageUploadUrls(
images.map((img) => ({ name: img.file.name, type: img.file.type })),
)
if (!urlResult.success) {
setError(urlResult.error)
return
}
// Upload each file directly to Supabase (bypasses Vercel size limit)
const uploadResults = await Promise.allSettled(
urlResult.uploads.map(async ({ path, signedUrl }, i) => {
const file = images[i]?.file
if (!file) return null
const res = await fetch(signedUrl, {
method: 'PUT',
headers: { 'Content-Type': file.type },
body: file,
})
if (!res.ok) {
console.error(`Upload failed for ${file.name}: ${res.status}`)
return null
}
return path
}),
)
imagePaths = uploadResults
.filter(
(r): r is PromiseFulfilledResult<string> =>
r.status === 'fulfilled' && r.value !== null,
)
.map((r) => r.value)
}
const result = await submitFeedback({
message,
projectId,
sceneGraph,
imagePaths,
})
if (result.success) {
setSent(true)
setTimeout(() => setOpen(false), 1500)
} else {
setError(result.error)
}
} finally {
setIsSubmitting(false)
}
}
return (
<>
<button
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}>
<DialogContent
className="sm:max-w-[460px]"
onDragEnter={onDragEnter}
onDragLeave={onDragLeave}
onDragOver={onDragOver}
onDrop={onDrop}
>
{/* 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="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>
</div>
</div>
)}
<DialogHeader>
<DialogTitle>Send Feedback</DialogTitle>
<DialogDescription>We&apos;d love to hear your thoughts</DialogDescription>
</DialogHeader>
{sent ? (
<p className="py-4 text-center text-sm text-muted-foreground">
Thanks for your feedback!
</p>
) : (
<form className="space-y-4" onSubmit={handleSubmit}>
<div>
<label htmlFor="feedback-message" className="text-sm font-medium">
Your feedback
</label>
<textarea
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
/>
</div>
{/* Image thumbnails */}
{images.length > 0 && (
<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"
>
<img src={img.url} alt="" className="h-full w-full object-cover" />
<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"
>
<X className="h-4 w-4 text-white" />
</button>
</div>
))}
</div>
)}
{error && <p className="text-sm text-destructive">{error}</p>}
<div className="flex items-center justify-between">
{/* Subtle attach button */}
<button
type="button"
onClick={() => fileInputRef.current?.click()}
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"
>
<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"
onChange={(e) => {
if (e.target.files) addFiles(e.target.files)
e.target.value = ''
}}
/>
<div className="flex gap-2">
<Button
type="button"
variant="outline"
onClick={handleClose}
disabled={isSubmitting}
>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting || !message.trim()}>
{isSubmitting ? 'Sending...' : 'Send Feedback'}
</Button>
</div>
</div>
</form>
)}
</DialogContent>
</Dialog>
</>
)
}
-280
View File
@@ -1,280 +0,0 @@
'use client'
import { AnimatePresence, motion } from 'framer-motion'
import { Howl } from 'howler'
import { Disc3, Settings2, SkipBack, SkipForward, Volume2, VolumeX } from 'lucide-react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { Slider } from '@/components/ui/slider'
import { cn } from '@/lib/utils'
import useAudio from '@/store/use-audio'
const PLAYLIST = [
{
title: 'Ballroom in Miniature',
file: '/audios/radios/classic/Ballroom in Miniature.mp3',
},
{
title: 'Blueprints in Springtime',
file: '/audios/radios/classic/Blueprints in Springtime.mp3',
},
{
title: 'Clockwork Tea Party',
file: '/audios/radios/classic/Clockwork Tea Party.mp3',
},
{
title: 'Clockwork Tea Party (Alternate)',
file: '/audios/radios/classic/Clockwork Tea Party (Alternate).mp3',
},
{
title: 'Clockwork Teacups',
file: '/audios/radios/classic/Clockwork Teacups.mp3',
},
{
title: 'Evening in the Parlor',
file: '/audios/radios/classic/Evening in the Parlor.mp3',
},
{
title: 'Glass Atrium',
file: '/audios/radios/classic/Glass Atrium.mp3',
},
{
title: 'Moonlight On The Drafting Table',
file: '/audios/radios/classic/Moonlight On The Drafting Table.mp3',
},
{
title: 'Sunlit Garden Reverie',
file: '/audios/radios/classic/Sunlit Garden Reverie.mp3',
},
{
title: 'Sunlit Waltz in Pastel Hues',
file: '/audios/radios/classic/Sunlit Waltz in Pastel Hues.mp3',
},
]
// Shuffle array helper
function shuffleArray<T>(array: T[]): T[] {
const shuffled = [...array]
for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1))
;[shuffled[i], shuffled[j]] = [shuffled[j]!, shuffled[i]!]
}
return shuffled
}
export function PascalRadio() {
const [shuffledPlaylist] = useState(() => shuffleArray(PLAYLIST))
const [currentTrackIndex, setCurrentTrackIndex] = useState(0)
const { masterVolume, radioVolume, muted, isRadioPlaying, setRadioPlaying } = useAudio()
const soundRef = useRef<Howl | null>(null)
const [isOpen, setIsOpen] = useState(false)
const containerRef = useRef<HTMLDivElement>(null)
const currentTrack = shuffledPlaylist[currentTrackIndex]!
// Calculate effective volume (masterVolume * radioVolume, both are 0-100)
const effectiveVolume = (masterVolume / 100) * (radioVolume / 100)
// Keep a ref so the track-init effect can read current volume/muted/isRadioPlaying
// without those values being part of its dependency array (which would restart the song).
const effectiveVolumeRef = useRef(effectiveVolume)
const mutedRef = useRef(muted)
const isPlayingRef = useRef(isRadioPlaying)
effectiveVolumeRef.current = effectiveVolume
mutedRef.current = muted
isPlayingRef.current = isRadioPlaying
const handleNext = useCallback(() => {
setCurrentTrackIndex((prev) => (prev + 1) % shuffledPlaylist.length)
}, [shuffledPlaylist.length])
const handlePrevious = useCallback(() => {
setCurrentTrackIndex((prev) => (prev - 1 + shuffledPlaylist.length) % shuffledPlaylist.length)
}, [shuffledPlaylist.length])
// Initialize Howler only when the track changes — not on volume/mute/play-state changes.
// Volume and mute are handled by the separate effect below.
useEffect(() => {
if (soundRef.current) {
soundRef.current.unload()
}
const wasPlaying = isPlayingRef.current
soundRef.current = new Howl({
src: [currentTrack.file],
volume: mutedRef.current ? 0 : effectiveVolumeRef.current,
onend: handleNext,
})
if (wasPlaying && !mutedRef.current) {
soundRef.current?.play()
}
return () => {
soundRef.current?.unload()
}
}, [handleNext, currentTrack.file])
// Update volume when settings change
useEffect(() => {
if (soundRef.current) {
soundRef.current.volume(muted ? 0 : effectiveVolume)
// Pause if muted, resume if unmuted and was playing
if (muted && isRadioPlaying) {
soundRef.current.pause()
} else if (!muted && isRadioPlaying && !soundRef.current.playing()) {
soundRef.current.play()
} else if (!isRadioPlaying && soundRef.current.playing()) {
soundRef.current.pause()
}
}
}, [effectiveVolume, muted, isRadioPlaying])
const handlePlayPause = () => {
if (!soundRef.current || muted) return
if (isRadioPlaying) {
soundRef.current.pause()
} else {
soundRef.current.play()
}
setRadioPlaying(!isRadioPlaying)
}
const handleVolumeChange = (value: number[]) => {
useAudio.setState({ radioVolume: value[0] })
}
// Handle click outside to close
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setIsOpen(false)
}
}
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside)
}
return () => {
document.removeEventListener('mousedown', handleClickOutside)
}
}, [isOpen])
return (
<motion.div
ref={containerRef}
layout
onClick={() => {
if (!isOpen) setIsOpen(true)
}}
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 gap-2">
<Disc3 className={cn('h-4 w-4 shrink-0', isRadioPlaying && 'animate-spin')} />
<span className="hidden sm:inline whitespace-nowrap">Radio Pascal</span>
</div>
<div className="flex items-center gap-2">
<div
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()
e.stopPropagation()
handlePlayPause()
}
}}
>
{isRadioPlaying ? (
<Volume2 className="h-3.5 w-3.5" />
) : (
<VolumeX className="h-3.5 w-3.5" />
)}
</div>
<button
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>
</div>
</div>
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ 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" />
{/* Current song info with prev/next */}
<div>
<p className="text-xs text-muted-foreground mb-2">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"
>
<SkipBack className="h-4 w-4" />
</button>
<p
className="text-sm font-medium text-center flex-1 truncate"
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"
>
<SkipForward className="h-4 w-4" />
</button>
</div>
</div>
{/* Volume control */}
<div className="flex items-center gap-2">
<Volume2 className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
<Slider
value={[radioVolume]}
onValueChange={handleVolumeChange}
max={100}
step={1}
className="flex-1"
aria-label="Radio Volume"
/>
<span className="w-8 text-right text-xs text-muted-foreground shrink-0">
{radioVolume}%
</span>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
)
}
-16
View File
@@ -1,16 +0,0 @@
'use client'
import { Eye } from 'lucide-react'
import useEditor from '@/store/use-editor'
export function PreviewButton() {
return (
<button
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>
</button>
)
}
@@ -1,77 +0,0 @@
import { type AnyNodeId, sceneRegistry, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useEffect } from 'react'
import useEditor from '@/store/use-editor'
export const CeilingSystem = () => {
const tool = useEditor((state) => state.tool)
const selectedItem = useEditor((state) => state.selectedItem)
const movingNode = useEditor((state) => state.movingNode)
const selectedIds = useViewer((state) => state.selection.selectedIds)
const activeLevelId = useViewer((state) => state.selection.levelId)
useEffect(() => {
const nodes = useScene.getState().nodes
const levelsToShowCeilings = new Set<string>()
const isCeilingToolActive =
tool === 'ceiling' ||
selectedItem?.attachTo === 'ceiling' ||
(movingNode?.type === 'item' && movingNode?.asset?.attachTo === 'ceiling')
if (isCeilingToolActive && activeLevelId) {
levelsToShowCeilings.add(activeLevelId)
}
for (const id of selectedIds) {
let currentId: string | null = id
let isCeilingRelated = false
let levelId: string | null = null
while (currentId && nodes[currentId as AnyNodeId]) {
const node = nodes[currentId as AnyNodeId]
if (node?.type === 'ceiling') {
isCeilingRelated = true
}
if (node?.type === 'level') {
levelId = node.id
break
}
currentId = node?.parentId as string | null
}
if (isCeilingRelated && levelId) {
levelsToShowCeilings.add(levelId)
}
}
const ceilings = sceneRegistry.byType.ceiling
ceilings.forEach((ceiling) => {
const mesh = sceneRegistry.nodes.get(ceiling)
if (mesh) {
const ceilingGrid = mesh.getObjectByName('ceiling-grid')
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)) {
belongsToVisibleLevel = true
break
}
currentId = node?.parentId as string | null
}
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
}
}
})
}, [tool, selectedItem, movingNode, selectedIds, activeLevelId])
return null
}
@@ -1,183 +0,0 @@
'use client'
import { useScene, type ZoneNode } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { Check, Pencil } from 'lucide-react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { useShallow } from 'zustand/react/shallow'
import useEditor from '@/store/use-editor'
// ─── Per-zone label editor ────────────────────────────────────────────────────
function ZoneLabelEditor({ zoneId }: { zoneId: ZoneNode['id'] }) {
const zone = useScene((s) => s.nodes[zoneId] as ZoneNode | undefined)
const updateNode = useScene((s) => s.updateNode)
const setSelection = useViewer((s) => s.setSelection)
const [editing, setEditing] = useState(false)
const [value, setValue] = useState('')
const inputRef = useRef<HTMLInputElement>(null)
const [labelEl, setLabelEl] = useState<HTMLElement | null>(null)
// 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])
// Setup: find the label element, enable pointer events, and hide the
// zone-renderer's own text node (children[0]) — we replace it via portal.
useEffect(() => {
const el = document.getElementById(`${zoneId}-label`)
if (!el) return
setLabelEl(el)
const textEl = el.children[0] as HTMLElement | undefined
if (textEl) textEl.style.display = 'none'
return () => {
if (textEl) textEl.style.display = ''
}
}, [zoneId])
// Focus + select-all when entering edit mode
useEffect(() => {
if (editing) {
inputRef.current?.focus()
inputRef.current?.select()
}
}, [editing])
const save = useCallback(() => {
const trimmed = value.trim()
if (trimmed !== (zone?.name ?? '')) {
updateNode(zoneId, { name: trimmed || undefined })
}
setEditing(false)
}, [value, zone?.name, updateNode, zoneId])
const cancel = useCallback(() => {
setValue(zone?.name ?? '')
setEditing(false)
}, [zone?.name])
if (!labelEl) return null
const shadowColor = zone?.color ?? '#6366f1'
const textShadow = [
`-1px -1px 0 ${shadowColor}`,
` 1px -1px 0 ${shadowColor}`,
`-1px 1px 0 ${shadowColor}`,
` 1px 1px 0 ${shadowColor}`,
].join(',')
// order: -1 puts this flex item before children[0] (hidden) and children[1] (pin)
const sharedStyle: React.CSSProperties = {
order: -1,
color: 'white',
textShadow,
fontSize: 14,
fontFamily: 'sans-serif',
userSelect: 'none',
pointerEvents: 'auto',
display: 'inline-flex',
alignItems: 'center',
gap: 4,
whiteSpace: 'nowrap',
}
return createPortal(
editing ? (
<div
style={sharedStyle}
onMouseDown={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
>
<input
ref={inputRef}
type="text"
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={(e) => {
e.stopPropagation()
if (e.key === 'Enter') { e.preventDefault(); save() }
if (e.key === 'Escape') { e.preventDefault(); cancel() }
}}
onBlur={save}
onClick={(e) => e.stopPropagation()}
style={{
width: `${Math.max((value || zone?.name || '').length + 1, 4)}ch`,
border: 'none',
borderBottom: `1px solid ${shadowColor}`,
background: 'transparent',
color: 'white',
textShadow,
outline: 'none',
padding: 0,
margin: 0,
fontSize: 'inherit',
lineHeight: 'inherit',
fontFamily: 'inherit',
textAlign: 'center',
}}
/>
<button
type="button"
onClick={(e) => { e.stopPropagation(); save() }}
onMouseDown={(e) => e.stopPropagation()}
style={{
background: 'none',
border: 'none',
color: 'white',
cursor: 'pointer',
padding: 0,
display: 'inline-flex',
alignItems: 'center',
}}
>
<Check size={12} />
</button>
</div>
) : (
<button
type="button"
style={{ ...sharedStyle, background: 'none', border: 'none', cursor: 'text', padding: 0 }}
onClick={(e) => {
e.stopPropagation()
setSelection({ zoneId })
setValue(zoneNameRef.current)
setEditing(true)
}}
onMouseDown={(e) => e.stopPropagation()}
>
<span>{zone?.name}</span>
<span style={{ display: 'inline-flex', alignItems: 'center', opacity: 0.55 }}>
<Pencil size={10} />
</span>
</button>
),
labelEl,
)
}
// ─── System: rendered in the main React tree (outside Canvas) ─────────────────
export function ZoneLabelEditorSystem() {
const zoneIds = useScene(
useShallow((s) =>
Object.values(s.nodes)
.filter((n) => n.type === 'zone')
.map((n) => n.id as ZoneNode['id']),
),
)
const structureLayer = useEditor((s) => s.structureLayer)
const mode = useEditor((s) => s.mode)
if (structureLayer !== 'zones' || mode !== 'select') return null
return (
<>
{zoneIds.map((id) => (
<ZoneLabelEditor key={id} zoneId={id} />
))}
</>
)
}
@@ -1,41 +0,0 @@
import { sceneRegistry, useScene, type ZoneNode } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useFrame } from '@react-three/fiber'
import useEditor from '@/store/use-editor'
export const ZoneSystem = () => {
useFrame(() => {
const structureLayer = useEditor.getState().structureLayer
const levelMode = useViewer.getState().levelMode
const selectedLevelId = useViewer.getState().selection.levelId
const visible = structureLayer === 'zones'
const zones = sceneRegistry.byType.zone || new Set()
const nodes = useScene.getState().nodes
zones.forEach((zoneId) => {
const obj = sceneRegistry.nodes.get(zoneId)
if (!obj) return
const zone = nodes[zoneId as ZoneNode['id']] as ZoneNode | undefined
// In solo mode, hide labels for zones not on the current level
const isOnSelectedLevel = zone?.parentId === selectedLevelId
const hideInSoloMode = levelMode === 'solo' && selectedLevelId && !isOnSelectedLevel
if (obj.visible !== visible) {
obj.visible = visible
}
// Hide label if zone layer is off OR if in solo mode on a different level
const showLabel = visible && !hideInSoloMode
const targetOpacity = showLabel ? '1' : '0'
const labelEl = document.getElementById(`${zoneId}-label`)
if (labelEl && labelEl.style.opacity !== targetOpacity) {
labelEl.style.opacity = targetOpacity
}
})
})
return null
}
@@ -1,42 +0,0 @@
import { resolveLevelId, type CeilingNode, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useCallback } from 'react'
import { PolygonEditor } from '../shared/polygon-editor'
interface CeilingBoundaryEditorProps {
ceilingId: CeilingNode['id']
}
/**
* Ceiling boundary editor - allows editing ceiling polygon vertices for a specific ceiling
* Uses the generic PolygonEditor component
*/
export const CeilingBoundaryEditor: React.FC<CeilingBoundaryEditorProps> = ({ ceilingId }) => {
const ceilingNode = useScene((state) => state.nodes[ceilingId])
const updateNode = useScene((state) => state.updateNode)
const setSelection = useViewer((state) => state.setSelection)
const ceiling = ceilingNode?.type === 'ceiling' ? (ceilingNode as CeilingNode) : null
const handlePolygonChange = useCallback(
(newPolygon: Array<[number, number]>) => {
updateNode(ceilingId, { polygon: newPolygon })
// Re-assert selection so the ceiling stays selected after the edit
setSelection({ selectedIds: [ceilingId] })
},
[ceilingId, updateNode, setSelection],
)
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)}
surfaceHeight={ceiling.height ?? 2.5}
/>
)
}
@@ -1,47 +0,0 @@
import { resolveLevelId, type CeilingNode, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useCallback } from 'react'
import { PolygonEditor } from '../shared/polygon-editor'
interface CeilingHoleEditorProps {
ceilingId: CeilingNode['id']
holeIndex: number
}
/**
* Ceiling hole editor - allows editing a specific hole polygon within a ceiling
* Uses the generic PolygonEditor component
*/
export const CeilingHoleEditor: React.FC<CeilingHoleEditorProps> = ({ ceilingId, holeIndex }) => {
const ceilingNode = useScene((state) => state.nodes[ceilingId])
const updateNode = useScene((state) => state.updateNode)
const setSelection = useViewer((state) => state.setSelection)
const ceiling = ceilingNode?.type === 'ceiling' ? (ceilingNode as CeilingNode) : null
const holes = ceiling?.holes || []
const hole = holes[holeIndex]
const handlePolygonChange = useCallback(
(newPolygon: Array<[number, number]>) => {
const updatedHoles = [...holes]
updatedHoles[holeIndex] = newPolygon
updateNode(ceilingId, { holes: updatedHoles })
// Re-assert selection so the ceiling stays selected after the edit
setSelection({ selectedIds: [ceilingId] })
},
[ceilingId, holeIndex, holes, updateNode, setSelection],
)
if (!ceiling || !hole || hole.length < 3) return null
return (
<PolygonEditor
polygon={hole}
color="#ef4444" // red for holes
onPolygonChange={handlePolygonChange}
minVertices={3}
levelId={resolveLevelId(ceiling, useScene.getState().nodes)}
surfaceHeight={ceiling.height ?? 2.5}
/>
)
}
@@ -1,401 +0,0 @@
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 { mix, positionLocal } from 'three/tsl'
import { EDITOR_LAYER } from '@/lib/constants'
import { sfxEmitter } from '@/lib/sfx-bus'
import { CursorSphere } from '../shared/cursor-sphere'
const CEILING_HEIGHT = 2.52
const GRID_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],
): [number, number] => {
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)
// Calculate distances to horizontal, vertical, and diagonal lines
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)
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]
}
}
/**
* Creates a ceiling with the given polygon points and returns its ID
*/
const commitCeilingDrawing = (levelId: LevelNode['id'], points: Array<[number, number]>): string => {
const { createNode, nodes } = useScene.getState()
// Count existing ceilings for naming
const ceilingCount = Object.values(nodes).filter((n) => n.type === 'ceiling').length
const name = `Ceiling ${ceilingCount + 1}`
const ceiling = CeilingNode.parse({
name,
polygon: points,
})
createNode(ceiling, levelId)
sfxEmitter.emit('sfx:structure-build')
return ceiling.id
}
export const CeilingTool: React.FC = () => {
const cursorRef = useRef<Group>(null)
const gridCursorRef = useRef<Group>(null)
const mainLineRef = useRef<Line>(null!)
const closingLineRef = useRef<Line>(null!)
const groundMainLineRef = useRef<Line>(null!)
const groundClosingLineRef = useRef<Line>(null!)
const verticalLineRef = useRef<Line>(null!)
const currentLevelId = useViewer((state) => state.selection.levelId)
const setSelection = useViewer((state) => state.setSelection)
const [points, setPoints] = useState<Array<[number, number]>>([])
const [cursorPosition, setCursorPosition] = useState<[number, number]>([0, 0])
const [snappedCursorPosition, setSnappedCursorPosition] = useState<[number, number]>([0, 0])
const [levelY, setLevelY] = useState(0)
const previousSnappedPointRef = useRef<[number, number] | null>(null)
const shiftPressed = useRef(false)
// 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)]),
[],
)
// opacityNode: positionLocal.y is 0 at grid, H at ceiling → fade from 0.6 to 0
const gradientOpacityNode = useMemo(
() => mix(0.6, 0.0, positionLocal.y.div(CEILING_HEIGHT - GRID_OFFSET).clamp()),
[],
)
// Update cursor position and lines on grid move
useEffect(() => {
if (!currentLevelId) return
const onGridMove = (event: GridEvent) => {
if (!cursorRef.current || !gridCursorRef.current) return
const gridX = Math.round(event.position[0] * 2) / 2
const gridZ = Math.round(event.position[2] * 2) / 2
const gridPosition: [number, number] = [gridX, gridZ]
setCursorPosition(gridPosition)
setLevelY(event.position[1])
const ceilingY = event.position[1] + CEILING_HEIGHT
const gridY = event.position[1] + GRID_OFFSET
// 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)
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])
) {
sfxEmitter.emit('sfx:grid-snap')
}
previousSnappedPointRef.current = displayPoint
cursorRef.current.position.set(displayPoint[0], ceilingY, displayPoint[1])
gridCursorRef.current.position.set(displayPoint[0], gridY, displayPoint[1])
if (verticalLineRef.current) {
verticalLineRef.current.position.set(displayPoint[0], gridY, displayPoint[1])
}
}
const onGridClick = (_event: GridEvent) => {
if (!currentLevelId) return
// Use the last displayed snapped position (respects Shift state from onGridMove)
const clickPoint = previousSnappedPointRef.current ?? cursorPosition
// Check if clicking on the first point to close the shape
const firstPoint = points[0]
if (
points.length >= 3 &&
firstPoint &&
Math.abs(clickPoint[0] - firstPoint[0]) < 0.25 &&
Math.abs(clickPoint[1] - firstPoint[1]) < 0.25
) {
// Create the ceiling and select it
const ceilingId = commitCeilingDrawing(currentLevelId, points)
setSelection({ selectedIds: [ceilingId] })
setPoints([])
} else {
// Add point to polygon
setPoints([...points, clickPoint])
}
}
const onGridDoubleClick = (_event: GridEvent) => {
if (!currentLevelId) return
// Need at least 3 points to form a polygon
if (points.length >= 3) {
const ceilingId = commitCeilingDrawing(currentLevelId, points)
setSelection({ selectedIds: [ceilingId] })
setPoints([])
}
}
const onCancel = () => {
setPoints([])
}
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)
emitter.on('grid:move', onGridMove)
emitter.on('grid:click', onGridClick)
emitter.on('grid:double-click', onGridDoubleClick)
emitter.on('tool:cancel', onCancel)
return () => {
document.removeEventListener('keydown', onKeyDown)
document.removeEventListener('keyup', onKeyUp)
emitter.off('grid:move', onGridMove)
emitter.off('grid:click', onGridClick)
emitter.off('grid:double-click', onGridDoubleClick)
emitter.off('tool:cancel', onCancel)
}
}, [currentLevelId, points, cursorPosition, setSelection])
// Update line geometries when points change
useEffect(() => {
if (!mainLineRef.current || !closingLineRef.current) return
if (points.length === 0) {
mainLineRef.current.visible = false
closingLineRef.current.visible = false
return
}
const ceilingY = levelY + CEILING_HEIGHT
const snappedCursor = snappedCursorPosition
// Build main line points
const linePoints: Vector3[] = points.map(([x, z]) => new Vector3(x, ceilingY, z))
linePoints.push(new Vector3(snappedCursor[0], ceilingY, snappedCursor[1]))
const gridY = levelY + GRID_OFFSET
const groundLinePoints: Vector3[] = points.map(([x, z]) => new Vector3(x, gridY, z))
groundLinePoints.push(new Vector3(snappedCursor[0], gridY, snappedCursor[1]))
// Update main line
if (linePoints.length >= 2) {
mainLineRef.current.geometry.dispose()
mainLineRef.current.geometry = new BufferGeometry().setFromPoints(linePoints)
mainLineRef.current.visible = true
groundMainLineRef.current.geometry.dispose()
groundMainLineRef.current.geometry = new BufferGeometry().setFromPoints(groundLinePoints)
groundMainLineRef.current.visible = true
} else {
mainLineRef.current.visible = false
groundMainLineRef.current.visible = false
}
// Update closing line (from cursor back to first point)
const firstPoint = points[0]
if (points.length >= 2 && firstPoint) {
const closingPoints = [
new Vector3(snappedCursor[0], ceilingY, snappedCursor[1]),
new Vector3(firstPoint[0], ceilingY, firstPoint[1]),
]
closingLineRef.current.geometry.dispose()
closingLineRef.current.geometry = new BufferGeometry().setFromPoints(closingPoints)
closingLineRef.current.visible = true
const groundClosingPoints = [
new Vector3(snappedCursor[0], gridY, snappedCursor[1]),
new Vector3(firstPoint[0], gridY, firstPoint[1]),
]
groundClosingLineRef.current.geometry.dispose()
groundClosingLineRef.current.geometry = new BufferGeometry().setFromPoints(groundClosingPoints)
groundClosingLineRef.current.visible = true
} else {
closingLineRef.current.visible = false
groundClosingLineRef.current.visible = false
}
}, [points, snappedCursorPosition, levelY])
// Create preview shape when we have 3+ points
const previewShape = useMemo(() => {
if (points.length < 3) return null
const snappedCursor = snappedCursorPosition
const allPoints = [...points, snappedCursor]
// 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 (!firstPt) return null
const shape = new Shape()
shape.moveTo(firstPt[0], -firstPt[1])
for (let i = 1; i < allPoints.length; i++) {
const pt = allPoints[i]
if (pt) {
shape.lineTo(pt[0], -pt[1])
}
}
shape.closePath()
return shape
}, [points, snappedCursorPosition])
return (
<group>
{/* Cursor at ceiling height */}
<CursorSphere ref={cursorRef} />
{/* Grid-level cursor indicator */}
<mesh ref={gridCursorRef} rotation={[-Math.PI / 2, 0, 0]} renderOrder={2} layers={EDITOR_LAYER}>
<ringGeometry args={[0.15, 0.2, 32]} />
<meshBasicMaterial color="#818cf8" side={DoubleSide} depthTest={false} depthWrite={true} opacity={0.5} 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>
{/* Preview fill (Top) */}
{previewShape && (
<mesh
frustumCulled={false}
layers={EDITOR_LAYER}
position={[0, levelY + CEILING_HEIGHT, 0]}
rotation={[-Math.PI / 2, 0, 0]}
>
<shapeGeometry args={[previewShape]} />
<meshBasicMaterial
color="#818cf8"
depthTest={false}
opacity={0.15}
side={DoubleSide}
transparent
/>
</mesh>
)}
{/* Preview fill (Ground) */}
{previewShape && (
<mesh
frustumCulled={false}
layers={EDITOR_LAYER}
position={[0, levelY + GRID_OFFSET, 0]}
rotation={[-Math.PI / 2, 0, 0]}
>
<shapeGeometry args={[previewShape]} />
<meshBasicMaterial
color="#818cf8"
depthTest={false}
opacity={0.1}
side={DoubleSide}
transparent
/>
</mesh>
)}
{/* Main line */}
{/* @ts-ignore */}
<line ref={mainLineRef} frustumCulled={false} renderOrder={1} visible={false} layers={EDITOR_LAYER}>
<bufferGeometry />
<lineBasicNodeMaterial color="#818cf8" linewidth={3} depthTest={false} depthWrite={false} />
</line>
{/* Closing line */}
{/* @ts-ignore */}
<line ref={closingLineRef} frustumCulled={false} renderOrder={1} visible={false} layers={EDITOR_LAYER}>
<bufferGeometry />
<lineBasicNodeMaterial
color="#818cf8"
linewidth={2}
depthTest={false}
depthWrite={false}
opacity={0.5}
transparent
/>
</line>
{/* Ground main line */}
{/* @ts-ignore */}
<line ref={groundMainLineRef} frustumCulled={false} renderOrder={1} visible={false} layers={EDITOR_LAYER}>
<bufferGeometry />
<lineBasicNodeMaterial color="#818cf8" linewidth={3} depthTest={false} depthWrite={false} opacity={0.3} transparent />
</line>
{/* Ground closing line */}
{/* @ts-ignore */}
<line ref={groundClosingLineRef} frustumCulled={false} renderOrder={1} visible={false} layers={EDITOR_LAYER}>
<bufferGeometry />
<lineBasicNodeMaterial
color="#818cf8"
linewidth={2}
depthTest={false}
depthWrite={false}
opacity={0.15}
transparent
/>
</line>
{/* Point markers */}
{points.map(([x, z], index) => (
<CursorSphere
key={index}
position={[x, levelY + CEILING_HEIGHT + 0.01, z]}
color="#818cf8"
showTooltip={false}
/>
))}
</group>
)
}
@@ -1,102 +0,0 @@
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.
*/
export function wallLocalToWorld(
wallNode: WallNode,
localX: number,
localY: number,
levelYOffset = 0,
slabElevation = 0,
): [number, number, number] {
const wallAngle = Math.atan2(
wallNode.end[1] - wallNode.start[1],
wallNode.end[0] - wallNode.start[0],
)
return [
wallNode.start[0] + localX * Math.cos(wallAngle),
slabElevation + localY + levelYOffset,
wallNode.start[1] + localX * Math.sin(wallAngle),
]
}
/**
* Clamps door center X so it stays fully within wall bounds.
* Y is always height/2 — doors sit at floor level.
*/
export function clampToWall(
wallNode: WallNode,
localX: number,
width: number,
height: number,
): { clampedX: number; clampedY: number } {
const dx = wallNode.end[0] - wallNode.start[0]
const dz = wallNode.end[1] - wallNode.start[1]
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
return { clampedX, clampedY }
}
/**
* Checks if a proposed door position overlaps any existing wall children.
* Handles item, window, and door types.
*/
export function hasWallChildOverlap(
wallId: string,
clampedX: number,
clampedY: number,
width: number,
height: number,
ignoreId?: string,
): boolean {
const nodes = useScene.getState().nodes
const wallNode = nodes[wallId as AnyNodeId] as WallNode | undefined
if (!wallNode) return true
const halfW = width / 2
const halfH = height / 2
const newBottom = clampedY - halfH
const newTop = clampedY + halfH
const newLeft = clampedX - halfW
const newRight = clampedX + halfW
for (const childId of wallNode.children) {
if (childId === ignoreId) continue
const child = nodes[childId as AnyNodeId]
if (!child) continue
let childLeft: number, childRight: number, childBottom: number, childTop: number
if (child.type === 'item') {
const item = child as ItemNode
if (item.asset.attachTo !== 'wall' && item.asset.attachTo !== 'wall-side') continue
const [w, h] = getScaledDimensions(item)
childLeft = item.position[0] - w / 2
childRight = item.position[0] + w / 2
childBottom = item.position[1]
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
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
childTop = door.position[1] + door.height / 2
} else {
continue
}
const xOverlap = newLeft < childRight && newRight > childLeft
const yOverlap = newBottom < childTop && newTop > childBottom
if (xOverlap && yOverlap) return true
}
return false
}
@@ -1,267 +0,0 @@
import {
type AnyNodeId,
DoorNode,
emitter,
sceneRegistry,
spatialGridManager,
useScene,
type WallEvent,
} from '@pascal-app/core'
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 {
calculateCursorRotation,
calculateItemRotation,
getSideFromNormal,
isValidWallSideFace,
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,
linewidth: 3,
depthTest: false,
depthWrite: false,
})
/**
* Door tool — places DoorNodes on walls only.
* Doors always sit at floor level (clampedY = height/2).
*/
export const DoorTool: React.FC = () => {
const draftRef = useRef<DoorNode | null>(null)
const cursorGroupRef = useRef<Group>(null!)
const edgesRef = useRef<LineSegments>(null!)
useEffect(() => {
useScene.temporal.getState().pause()
const getLevelId = () => useViewer.getState().selection.levelId
const getLevelYOffset = () => {
const id = getLevelId()
return id ? (sceneRegistry.nodes.get(id as AnyNodeId)?.position.y ?? 0) : 0
}
const getSlabElevation = (wallEvent: WallEvent) =>
spatialGridManager.getSlabElevationForWall(
wallEvent.node.parentId ?? '',
wallEvent.node.start,
wallEvent.node.end,
)
const markWallDirty = (wallId: string) => {
useScene.getState().dirtyNodes.add(wallId as AnyNodeId)
}
const destroyDraft = () => {
if (!draftRef.current) return
const wallId = draftRef.current.parentId
useScene.getState().deleteNode(draftRef.current.id)
draftRef.current = null
if (wallId) markWallDirty(wallId)
}
const hideCursor = () => {
if (cursorGroupRef.current) cursorGroupRef.current.visible = false
}
const updateCursor = (
worldPosition: [number, number, number],
cursorRotationY: number,
valid: boolean,
) => {
const group = cursorGroupRef.current
if (!group) return
group.visible = true
group.position.set(...worldPosition)
group.rotation.y = cursorRotationY
edgeMaterial.color.setHex(valid ? 0x22c55e : 0xef4444)
}
const onWallEnter = (event: WallEvent) => {
if (!isValidWallSideFace(event.normal)) return
const levelId = getLevelId()
if (!levelId) return
if (event.node.parentId !== levelId) return
destroyDraft()
const side = getSideFromNormal(event.normal)
const itemRotation = calculateItemRotation(event.normal)
const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)
const localX = snapToHalf(event.localPosition[0])
const width = 0.9
const height = 2.1
const { clampedX, clampedY } = clampToWall(event.node, localX, width, height)
const node = DoorNode.parse({
position: [clampedX, clampedY, 0],
rotation: [0, itemRotation, 0],
side,
wallId: event.node.id,
parentId: event.node.id,
metadata: { isTransient: true },
})
useScene.getState().createNode(node, event.node.id as AnyNodeId)
draftRef.current = node
const valid = !hasWallChildOverlap(event.node.id, clampedX, clampedY, width, height, node.id)
updateCursor(
wallLocalToWorld(event.node, clampedX, clampedY, getLevelYOffset(), getSlabElevation(event)),
cursorRotation,
valid,
)
event.stopPropagation()
}
const onWallMove = (event: WallEvent) => {
if (!isValidWallSideFace(event.normal)) return
if (event.node.parentId !== getLevelId()) return
const side = getSideFromNormal(event.normal)
const itemRotation = calculateItemRotation(event.normal)
const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)
const localX = snapToHalf(event.localPosition[0])
const width = draftRef.current?.width ?? 0.9
const height = draftRef.current?.height ?? 2.1
const { clampedX, clampedY } = clampToWall(event.node, localX, width, height)
if (draftRef.current) {
useScene.getState().updateNode(draftRef.current.id, {
position: [clampedX, clampedY, 0],
rotation: [0, itemRotation, 0],
side,
parentId: event.node.id,
wallId: event.node.id,
})
}
const valid = !hasWallChildOverlap(
event.node.id, clampedX, clampedY, width, height,
draftRef.current?.id,
)
updateCursor(
wallLocalToWorld(event.node, clampedX, clampedY, getLevelYOffset(), getSlabElevation(event)),
cursorRotation,
valid,
)
event.stopPropagation()
}
const onWallClick = (event: WallEvent) => {
if (!draftRef.current) return
if (!isValidWallSideFace(event.normal)) return
if (event.node.parentId !== getLevelId()) return
const side = getSideFromNormal(event.normal)
const itemRotation = calculateItemRotation(event.normal)
const localX = snapToHalf(event.localPosition[0])
const { clampedX, clampedY } = clampToWall(
event.node, localX,
draftRef.current.width, draftRef.current.height,
)
const valid = !hasWallChildOverlap(
event.node.id, clampedX, clampedY,
draftRef.current.width, draftRef.current.height,
draftRef.current.id,
)
if (!valid) return
const draft = draftRef.current
draftRef.current = null
useScene.getState().deleteNode(draft.id)
useScene.temporal.getState().resume()
const levelId = getLevelId()
const state = useScene.getState()
const doorCount = Object.values(state.nodes).filter((n) => {
if (n.type !== 'door') return false
const wall = n.parentId ? state.nodes[n.parentId as AnyNodeId] : undefined
return wall?.parentId === levelId
}).length
const name = `Door ${doorCount + 1}`
const node = DoorNode.parse({
name,
position: [clampedX, clampedY, 0],
rotation: [0, itemRotation, 0],
side,
wallId: event.node.id,
parentId: event.node.id,
width: draft.width,
height: draft.height,
frameThickness: draft.frameThickness,
frameDepth: draft.frameDepth,
threshold: draft.threshold,
thresholdHeight: draft.thresholdHeight,
hingesSide: draft.hingesSide,
swingDirection: draft.swingDirection,
segments: draft.segments,
handle: draft.handle,
handleHeight: draft.handleHeight,
handleSide: draft.handleSide,
doorCloser: draft.doorCloser,
panicBar: draft.panicBar,
panicBarHeight: draft.panicBarHeight,
})
useScene.getState().createNode(node, event.node.id as AnyNodeId)
useViewer.getState().setSelection({ selectedIds: [node.id] })
useScene.temporal.getState().pause()
sfxEmitter.emit('sfx:item-place')
event.stopPropagation()
}
const onWallLeave = () => {
destroyDraft()
hideCursor()
}
const onCancel = () => {
destroyDraft()
hideCursor()
}
emitter.on('wall:enter', onWallEnter)
emitter.on('wall:move', onWallMove)
emitter.on('wall:click', onWallClick)
emitter.on('wall:leave', onWallLeave)
emitter.on('tool:cancel', onCancel)
return () => {
destroyDraft()
hideCursor()
useScene.temporal.getState().resume()
emitter.off('wall:enter', onWallEnter)
emitter.off('wall:move', onWallMove)
emitter.off('wall:click', onWallClick)
emitter.off('wall:leave', onWallLeave)
emitter.off('tool:cancel', onCancel)
}
}, [])
// Cursor geometry: door outline (default 0.9 × 2.1 × 0.07)
const boxGeo = new BoxGeometry(0.9, 2.1, 0.07)
const edgesGeo = new EdgesGeometry(boxGeo)
boxGeo.dispose()
return (
<group ref={cursorGroupRef} visible={false}>
<lineSegments ref={edgesRef} geometry={edgesGeo} material={edgeMaterial} layers={EDITOR_LAYER} />
</group>
)
}
@@ -1,343 +0,0 @@
import {
type AnyNodeId,
DoorNode,
emitter,
sceneRegistry,
spatialGridManager,
useScene,
type WallEvent,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useEffect, useMemo, useRef } from 'react'
import { BoxGeometry, EdgesGeometry, type Group } from 'three'
import { LineBasicNodeMaterial } from 'three/webgpu'
import { EDITOR_LAYER } from '@/lib/constants'
import { sfxEmitter } from '@/lib/sfx-bus'
import useEditor from '@/store/use-editor'
import {
calculateCursorRotation,
calculateItemRotation,
getSideFromNormal,
isValidWallSideFace,
snapToHalf,
} from '../item/placement-math'
import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './door-math'
const edgeMaterial = new LineBasicNodeMaterial({
color: 0xef4444,
linewidth: 3,
depthTest: false,
depthWrite: false,
})
export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => {
const cursorGroupRef = useRef<Group>(null!)
const exitMoveMode = () => {
useEditor.getState().setMovingNode(null)
}
useEffect(() => {
useScene.temporal.getState().pause()
const meta = (typeof movingDoorNode.metadata === 'object' && movingDoorNode.metadata !== null)
? movingDoorNode.metadata as Record<string, unknown>
: {}
const isNew = !!meta.isNew
const original = {
position: [...movingDoorNode.position] as [number, number, number],
rotation: [...movingDoorNode.rotation] as [number, number, number],
side: movingDoorNode.side,
parentId: movingDoorNode.parentId,
wallId: movingDoorNode.wallId,
metadata: movingDoorNode.metadata,
}
if (!isNew) {
useScene.getState().updateNode(movingDoorNode.id, {
metadata: { ...meta, isTransient: true },
})
}
let currentWallId: string | null = movingDoorNode.parentId
const markWallDirty = (wallId: string | null) => {
if (wallId) useScene.getState().dirtyNodes.add(wallId as AnyNodeId)
}
const getLevelId = () => useViewer.getState().selection.levelId
const getLevelYOffset = () => {
const id = getLevelId()
return id ? (sceneRegistry.nodes.get(id as AnyNodeId)?.position.y ?? 0) : 0
}
const getSlabElevation = (wallEvent: WallEvent) =>
spatialGridManager.getSlabElevationForWall(
wallEvent.node.parentId ?? '',
wallEvent.node.start,
wallEvent.node.end,
)
const hideCursor = () => {
if (cursorGroupRef.current) cursorGroupRef.current.visible = false
}
const updateCursor = (
worldPosition: [number, number, number],
cursorRotationY: number,
valid: boolean,
) => {
const group = cursorGroupRef.current
if (!group) return
group.visible = true
group.position.set(...worldPosition)
group.rotation.y = cursorRotationY
edgeMaterial.color.setHex(valid ? 0x22c55e : 0xef4444)
}
const onWallEnter = (event: WallEvent) => {
if (!isValidWallSideFace(event.normal)) return
if (event.node.parentId !== getLevelId()) return
const side = getSideFromNormal(event.normal)
const itemRotation = calculateItemRotation(event.normal)
const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)
const localX = snapToHalf(event.localPosition[0])
const { clampedX, clampedY } = clampToWall(
event.node, localX,
movingDoorNode.width, movingDoorNode.height,
)
const prevWallId = currentWallId
currentWallId = event.node.id
useScene.getState().updateNode(movingDoorNode.id, {
position: [clampedX, clampedY, 0],
rotation: [0, itemRotation, 0],
side,
parentId: event.node.id,
wallId: event.node.id,
})
if (prevWallId && prevWallId !== event.node.id) markWallDirty(prevWallId)
markWallDirty(event.node.id)
const valid = !hasWallChildOverlap(
event.node.id, clampedX, clampedY,
movingDoorNode.width, movingDoorNode.height,
movingDoorNode.id,
)
updateCursor(
wallLocalToWorld(event.node, clampedX, clampedY, getLevelYOffset(), getSlabElevation(event)),
cursorRotation,
valid,
)
event.stopPropagation()
}
const onWallMove = (event: WallEvent) => {
if (!isValidWallSideFace(event.normal)) return
if (event.node.parentId !== getLevelId()) return
const side = getSideFromNormal(event.normal)
const itemRotation = calculateItemRotation(event.normal)
const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)
const localX = snapToHalf(event.localPosition[0])
const { clampedX, clampedY } = clampToWall(
event.node, localX,
movingDoorNode.width, movingDoorNode.height,
)
useScene.getState().updateNode(movingDoorNode.id, {
position: [clampedX, clampedY, 0],
rotation: [0, itemRotation, 0],
side,
parentId: event.node.id,
wallId: event.node.id,
})
if (currentWallId !== event.node.id) {
markWallDirty(currentWallId)
currentWallId = event.node.id
}
markWallDirty(event.node.id)
const valid = !hasWallChildOverlap(
event.node.id, clampedX, clampedY,
movingDoorNode.width, movingDoorNode.height,
movingDoorNode.id,
)
updateCursor(
wallLocalToWorld(event.node, clampedX, clampedY, getLevelYOffset(), getSlabElevation(event)),
cursorRotation,
valid,
)
event.stopPropagation()
}
const onWallClick = (event: WallEvent) => {
if (!isValidWallSideFace(event.normal)) return
if (event.node.parentId !== getLevelId()) return
const side = getSideFromNormal(event.normal)
const itemRotation = calculateItemRotation(event.normal)
const localX = snapToHalf(event.localPosition[0])
const { clampedX, clampedY } = clampToWall(
event.node, localX,
movingDoorNode.width, movingDoorNode.height,
)
const valid = !hasWallChildOverlap(
event.node.id, clampedX, clampedY,
movingDoorNode.width, movingDoorNode.height,
movingDoorNode.id,
)
if (!valid) return
let placedId: string
if (isNew) {
useScene.getState().deleteNode(movingDoorNode.id)
useScene.temporal.getState().resume()
const cloned = structuredClone(movingDoorNode) as any
delete cloned.id
const node = DoorNode.parse({
...cloned,
position: [clampedX, clampedY, 0],
rotation: [0, itemRotation, 0],
side,
wallId: event.node.id,
parentId: event.node.id,
})
useScene.getState().createNode(node, event.node.id as AnyNodeId)
placedId = node.id
} else {
useScene.getState().updateNode(movingDoorNode.id, {
position: original.position,
rotation: original.rotation,
side: original.side,
parentId: original.parentId,
wallId: original.wallId,
metadata: original.metadata,
})
useScene.temporal.getState().resume()
useScene.getState().updateNode(movingDoorNode.id, {
position: [clampedX, clampedY, 0],
rotation: [0, itemRotation, 0],
side,
parentId: event.node.id,
wallId: event.node.id,
metadata: {},
})
if (original.parentId && original.parentId !== event.node.id) {
markWallDirty(original.parentId)
}
placedId = movingDoorNode.id
}
markWallDirty(event.node.id)
useScene.temporal.getState().pause()
sfxEmitter.emit('sfx:item-place')
hideCursor()
useViewer.getState().setSelection({ selectedIds: [placedId] })
exitMoveMode()
event.stopPropagation()
}
const onWallLeave = () => {
hideCursor()
if (isNew) return
if (currentWallId && currentWallId !== original.parentId) {
markWallDirty(currentWallId)
}
currentWallId = original.parentId
useScene.getState().updateNode(movingDoorNode.id, {
position: original.position,
rotation: original.rotation,
side: original.side,
parentId: original.parentId,
wallId: original.wallId,
})
if (original.parentId) markWallDirty(original.parentId)
}
const onCancel = () => {
if (isNew) {
useScene.getState().deleteNode(movingDoorNode.id)
if (currentWallId) markWallDirty(currentWallId)
} else {
useScene.getState().updateNode(movingDoorNode.id, {
position: original.position,
rotation: original.rotation,
side: original.side,
parentId: original.parentId,
wallId: original.wallId,
metadata: original.metadata,
})
if (original.parentId) markWallDirty(original.parentId)
}
useScene.temporal.getState().resume()
hideCursor()
exitMoveMode()
}
emitter.on('wall:enter', onWallEnter)
emitter.on('wall:move', onWallMove)
emitter.on('wall:click', onWallClick)
emitter.on('wall:leave', onWallLeave)
emitter.on('tool:cancel', onCancel)
return () => {
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) {
useScene.getState().deleteNode(movingDoorNode.id)
if (currentWallId) markWallDirty(currentWallId)
} else {
useScene.getState().updateNode(movingDoorNode.id, {
position: original.position,
rotation: original.rotation,
side: original.side,
parentId: original.parentId,
wallId: original.wallId,
metadata: original.metadata,
})
if (original.parentId) markWallDirty(original.parentId)
}
}
useScene.temporal.getState().resume()
emitter.off('wall:enter', onWallEnter)
emitter.off('wall:move', onWallMove)
emitter.off('wall:click', onWallClick)
emitter.off('wall:leave', onWallLeave)
emitter.off('tool:cancel', onCancel)
}
}, [movingDoorNode])
const edgesGeo = useMemo(() => {
const boxGeo = new BoxGeometry(
movingDoorNode.width,
movingDoorNode.height,
movingDoorNode.frameDepth ?? 0.07,
)
const geo = new EdgesGeometry(boxGeo)
boxGeo.dispose()
return geo
}, [movingDoorNode])
return (
<group ref={cursorGroupRef} visible={false}>
<lineSegments geometry={edgesGeo} material={edgeMaterial} layers={EDITOR_LAYER} />
</group>
)
}
@@ -1,26 +0,0 @@
import { sfxEmitter } from '@/lib/sfx-bus'
import useEditor from '@/store/use-editor'
import { useDraftNode } from './use-draft-node'
import { usePlacementCoordinator } from './use-placement-coordinator'
export const ItemTool: React.FC = () => {
const selectedItem = useEditor((state) => state.selectedItem)
const draftNode = useDraftNode()
const cursor = usePlacementCoordinator({
asset: selectedItem!,
draftNode,
initDraft: (gridPosition) => {
if (!selectedItem?.attachTo) {
draftNode.create(gridPosition, selectedItem!)
}
},
onCommitted: () => {
sfxEmitter.emit('sfx:item-place')
return true
},
})
if (!selectedItem) return null
return <>{cursor}</>
}
@@ -1,74 +0,0 @@
import type { DoorNode, ItemNode, WindowNode } from '@pascal-app/core'
import { Vector3 } from 'three'
import { sfxEmitter } from '@/lib/sfx-bus'
import useEditor from '@/store/use-editor'
import { MoveDoorTool } from '../door/move-door-tool'
import { MoveWindowTool } from '../window/move-window-tool'
import type { PlacementState } from './placement-types'
import { useDraftNode } from './use-draft-node'
import { usePlacementCoordinator } from './use-placement-coordinator'
function getInitialState(node: {
asset: { attachTo?: string }
parentId: string | null
}): PlacementState {
const attachTo = node.asset.attachTo
if (attachTo === 'wall' || attachTo === 'wall-side') {
return { surface: 'wall', wallId: node.parentId, ceilingId: null, surfaceItemId: null }
}
if (attachTo === 'ceiling') {
return { surface: 'ceiling', wallId: null, ceilingId: node.parentId, surfaceItemId: null }
}
return { surface: 'floor', wallId: null, ceilingId: null, surfaceItemId: null }
}
function MoveItemContent({ movingNode }: { movingNode: ItemNode }) {
const draftNode = useDraftNode()
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),
// Preserve the original item's scale so Y-position calculations use the correct height
defaultScale: isNew ? movingNode.scale : undefined,
initDraft: (gridPosition) => {
if (isNew) {
// Duplicate: use the same create() path as ItemTool so ghost rendering works correctly.
// Floor items get a draft immediately; wall/ceiling items are created lazily on surface entry.
gridPosition.copy(new Vector3(...movingNode.position))
if (!movingNode.asset.attachTo) {
draftNode.create(gridPosition, movingNode.asset, movingNode.rotation, movingNode.scale)
}
} else {
draftNode.adopt(movingNode)
gridPosition.copy(new Vector3(...movingNode.position))
}
},
onCommitted: () => {
sfxEmitter.emit('sfx:item-place')
useEditor.getState().setMovingNode(null)
return false
},
onCancel: () => {
draftNode.destroy()
useEditor.getState().setMovingNode(null)
},
})
return <>{cursor}</>
}
export const MoveTool: React.FC = () => {
const movingNode = useEditor((state) => state.movingNode)
if (!movingNode) return null
if (movingNode.type === 'door') return <MoveDoorTool node={movingNode as DoorNode} />
if (movingNode.type === 'window') return <MoveWindowTool node={movingNode as WindowNode} />
return <MoveItemContent movingNode={movingNode as ItemNode} />
}
@@ -1,86 +0,0 @@
import { isObject } from '@pascal-app/core'
/**
* Snaps a position to 0.5 grid, with an offset to align item edges to grid lines.
* For items with dimensions like 2.5, the center would be at 1.25 from the edge,
* which doesn't align with 0.5 grid. This adds an offset so edges align instead.
*/
export function snapToGrid(position: number, dimension: number): number {
const halfDim = dimension / 2
const needsOffset = Math.abs(((halfDim * 2) % 1) - 0.5) < 0.01
const offset = needsOffset ? 0.25 : 0
return Math.round((position - offset) * 2) / 2 + offset
}
/**
* Snap a value to 0.5 increments (used for wall-local positions).
*/
export function snapToHalf(value: number): number {
return Math.round(value * 2) / 2
}
/**
* Calculate cursor rotation in WORLD space from wall normal and orientation.
*/
export function calculateCursorRotation(
normal: [number, number, number] | undefined,
wallStart: [number, number],
wallEnd: [number, number],
): number {
if (!normal) return 0
// Wall direction angle in world XZ plane
const wallAngle = Math.atan2(wallEnd[1] - wallStart[1], wallEnd[0] - wallStart[0])
// 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
}
}
/**
* Calculate item rotation in WALL-LOCAL space from normal.
* Items are children of the wall mesh, so their rotation is relative to wall's local space.
*/
export function calculateItemRotation(normal: [number, number, number] | undefined): number {
if (!normal) return 0
return normal[2] > 0 ? 0 : Math.PI
}
/**
* Determine which side of the wall based on the normal vector.
* In wall-local space, the wall runs along X-axis, so the normal points along Z-axis.
* Positive Z normal = 'front', Negative Z normal = 'back'
*/
export function getSideFromNormal(normal: [number, number, number] | undefined): 'front' | 'back' {
if (!normal) return 'front'
return normal[2] >= 0 ? 'front' : 'back'
}
/**
* Check if the normal indicates a valid wall side face (front or back).
* Filters out top face and thickness edges.
*
* In wall-local geometry space (after ExtrudeGeometry + rotateX):
* - X axis: along wall direction
* - Y axis: up (height)
* - Z axis: perpendicular to wall (thickness direction)
*
* So valid side faces have normals pointing in ±Z direction (local space).
*/
export function isValidWallSideFace(normal: [number, number, number] | undefined): boolean {
if (!normal) return false
return Math.abs(normal[2]) > 0.7
}
/**
* Strip the `isTransient` flag from node metadata before committing.
*/
export function stripTransient(meta: any): any {
if (!isObject(meta)) return meta
const { isTransient, ...rest } = meta as Record<string, any>
return rest
}
@@ -1,532 +0,0 @@
import type {
AnyNode,
AnyNodeId,
CeilingEvent,
CeilingNode,
GridEvent,
ItemEvent,
ItemNode,
WallEvent,
WallNode,
} 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,
getSideFromNormal,
isValidWallSideFace,
snapToGrid,
snapToHalf,
stripTransient,
} from './placement-math'
const DEFAULT_DIMENSIONS: [number, number, number] = [1, 1, 1]
// ============================================================================
// FLOOR STRATEGY
// ============================================================================
export const floorStrategy = {
/**
* Handle grid:move — update position when on floor surface.
* Returns null if currently on wall/ceiling.
*/
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 [dimX, , dimZ] = dims
const x = snapToGrid(event.position[0], dimX)
const z = snapToGrid(event.position[2], dimZ)
return {
gridPosition: [x, 0, z],
cursorPosition: [x, event.position[1], z],
cursorRotationY: 0,
nodeUpdate: { position: [x, 0, z] },
stopPropagation: false,
dirtyNodeId: null,
}
},
/**
* 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 {
if (ctx.state.surface !== 'floor') 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(
ctx.levelId,
pos,
getScaledDimensions(ctx.draftItem),
[0, 0, 0],
[ctx.draftItem.id],
).valid
if (!valid) return null
return {
nodeUpdate: {
position: pos,
parentId: ctx.levelId,
metadata: stripTransient(ctx.draftItem.metadata),
},
stopPropagation: false,
dirtyNodeId: null,
}
},
}
// ============================================================================
// WALL STRATEGY
// ============================================================================
export const wallStrategy = {
/**
* Handle wall:enter — transition from floor to wall surface.
* Returns null if item doesn't attach to walls, face is invalid, or wrong level.
* Auto-adjusts Y position to fit within wall bounds.
*/
enter(
ctx: PlacementContext,
event: WallEvent,
resolveLevelId: LevelResolver,
nodes: Record<string, AnyNode>,
validators: SpatialValidators,
): TransitionResult | null {
const attachTo = ctx.asset.attachTo
if (attachTo !== 'wall' && attachTo !== 'wall-side') return null
if (!isValidWallSideFace(event.normal)) return null
// Level guard
const wallLevelId = resolveLevelId(event.node, nodes)
if (ctx.levelId !== wallLevelId) return null
const side = getSideFromNormal(event.normal)
const itemRotation = calculateItemRotation(event.normal)
const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)
const x = snapToHalf(event.localPosition[0])
const y = snapToHalf(event.localPosition[1])
const z = snapToHalf(event.localPosition[2])
// Get auto-adjusted Y position from validator
const validation = validators.canPlaceOnWall(
ctx.levelId,
event.node.id,
x,
y,
ctx.draftItem ? getScaledDimensions(ctx.draftItem) : (ctx.asset.dimensions ?? DEFAULT_DIMENSIONS),
attachTo,
side,
[],
)
const adjustedY = validation.adjustedY ?? y
return {
stateUpdate: { surface: 'wall', wallId: event.node.id },
nodeUpdate: {
position: [x, adjustedY, z],
parentId: event.node.id,
side,
rotation: [0, itemRotation, 0],
},
cursorRotationY: cursorRotation,
gridPosition: [x, adjustedY, z],
cursorPosition: [
snapToHalf(event.position[0]),
snapToHalf(event.position[1]),
snapToHalf(event.position[2]),
],
stopPropagation: true,
}
},
/**
* Handle wall:move — update position while on wall.
* 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 {
if (ctx.state.surface !== 'wall') return null
if (!ctx.draftItem || !ctx.levelId) return null
if (!isValidWallSideFace(event.normal)) return null
const side = getSideFromNormal(event.normal)
const itemRotation = calculateItemRotation(event.normal)
const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)
const snappedX = snapToHalf(event.localPosition[0])
const snappedY = snapToHalf(event.localPosition[1])
const snappedZ = snapToHalf(event.localPosition[2])
// Get auto-adjusted Y position from validator
const validation = validators.canPlaceOnWall(
ctx.levelId,
event.node.id,
snappedX,
snappedY,
getScaledDimensions(ctx.draftItem),
ctx.draftItem.asset.attachTo as 'wall' | 'wall-side',
side,
[ctx.draftItem.id],
)
const adjustedY = validation.adjustedY ?? snappedY
return {
gridPosition: [snappedX, adjustedY, snappedZ],
cursorPosition: [
snapToHalf(event.position[0]),
snapToHalf(event.position[1]),
snapToHalf(event.position[2]),
],
cursorRotationY: cursorRotation,
nodeUpdate: {
position: [snappedX, adjustedY, snappedZ],
side,
rotation: [0, itemRotation, 0],
},
stopPropagation: true,
dirtyNodeId: event.node.id,
}
},
/**
* 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 {
if (ctx.state.surface !== 'wall') return null
if (!isValidWallSideFace(event.normal)) return null
if (!ctx.levelId || !ctx.draftItem) return null
const valid = validators.canPlaceOnWall(
ctx.levelId,
ctx.state.wallId as WallNode['id'],
ctx.gridPosition.x,
ctx.gridPosition.y,
getScaledDimensions(ctx.draftItem),
ctx.draftItem.asset.attachTo as 'wall' | 'wall-side',
ctx.draftItem.side,
[ctx.draftItem.id],
).valid
if (!valid) return null
return {
nodeUpdate: {
position: [ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z],
parentId: event.node.id,
side: ctx.draftItem.side,
rotation: ctx.draftItem.rotation,
metadata: stripTransient(ctx.draftItem.metadata),
},
stopPropagation: true,
dirtyNodeId: event.node.id,
}
},
/**
* Handle wall:leave — transition back to floor surface.
*/
leave(ctx: PlacementContext): TransitionResult | null {
if (ctx.state.surface !== 'wall') return null
return {
stateUpdate: { surface: 'floor', wallId: null },
nodeUpdate: {
position: [ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z],
parentId: ctx.levelId,
},
cursorRotationY: 0,
gridPosition: [ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z],
cursorPosition: [ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z],
stopPropagation: true,
}
},
}
// ============================================================================
// CEILING STRATEGY
// ============================================================================
export const ceilingStrategy = {
/**
* Handle ceiling:enter — transition from floor to ceiling surface.
* Returns null if item doesn't attach to ceilings or wrong level.
*/
enter(
ctx: PlacementContext,
event: CeilingEvent,
resolveLevelId: LevelResolver,
nodes: Record<string, AnyNode>,
): TransitionResult | null {
if (ctx.asset.attachTo !== 'ceiling') return null
// Level guard
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 [dimX, , dimZ] = dims
const itemHeight = dims[1]
const x = snapToGrid(event.position[0], dimX)
const z = snapToGrid(event.position[2], dimZ)
return {
stateUpdate: { surface: 'ceiling', ceilingId: event.node.id },
nodeUpdate: {
position: [x, -itemHeight, z],
parentId: event.node.id,
},
cursorRotationY: 0,
gridPosition: [x, -itemHeight, z],
cursorPosition: [x, event.position[1] - itemHeight, z],
stopPropagation: true,
}
},
/**
* Handle ceiling:move — update position while on ceiling.
*/
move(ctx: PlacementContext, event: CeilingEvent): PlacementResult | null {
if (ctx.state.surface !== 'ceiling') return null
if (!ctx.draftItem) return null
const dims = getScaledDimensions(ctx.draftItem)
const [dimX, , dimZ] = dims
const itemHeight = dims[1]
const x = snapToGrid(event.position[0], dimX)
const z = snapToGrid(event.position[2], dimZ)
return {
gridPosition: [x, -itemHeight, z],
cursorPosition: [x, event.position[1] - itemHeight, z],
cursorRotationY: 0,
nodeUpdate: null,
stopPropagation: true,
dirtyNodeId: null,
}
},
/**
* Handle ceiling:click — commit placement on ceiling.
*/
click(ctx: PlacementContext, event: CeilingEvent, validators: SpatialValidators): CommitResult | null {
if (ctx.state.surface !== 'ceiling') return null
if (!ctx.draftItem) return null
const pos: [number, number, number] = [
ctx.gridPosition.x,
ctx.gridPosition.y,
ctx.gridPosition.z,
]
const valid = validators.canPlaceOnCeiling(
ctx.state.ceilingId as CeilingNode['id'],
pos,
getScaledDimensions(ctx.draftItem),
ctx.draftItem.rotation,
[ctx.draftItem.id],
).valid
if (!valid) return null
return {
nodeUpdate: {
position: pos,
parentId: event.node.id,
metadata: stripTransient(ctx.draftItem.metadata),
},
stopPropagation: true,
dirtyNodeId: null,
}
},
/**
* Handle ceiling:leave — transition back to floor surface.
*/
leave(ctx: PlacementContext): TransitionResult | null {
if (ctx.state.surface !== 'ceiling') return null
return {
stateUpdate: { surface: 'floor', ceilingId: null },
nodeUpdate: {
position: [ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z],
parentId: ctx.levelId,
},
cursorRotationY: 0,
gridPosition: [ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z],
cursorPosition: [ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z],
stopPropagation: true,
}
},
}
// ============================================================================
// ITEM SURFACE STRATEGY
// ============================================================================
export const itemSurfaceStrategy = {
/**
* Handle item:enter — transition from floor to an item surface.
* Returns null if: item has no surface, our item doesn't fit, or it's the draft itself.
*/
enter(ctx: PlacementContext, event: ItemEvent): TransitionResult | null {
// Only floor items can be placed on surfaces
if (ctx.asset.attachTo) return null
const surfaceItem = event.node as ItemNode
// Don't surface-place on the draft itself
if (surfaceItem.id === ctx.draftItem?.id) return null
// Surface item must declare a surface
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 surfDims = getScaledDimensions(surfaceItem)
if (ourDims[0] > surfDims[0] || ourDims[2] > surfDims[2]) return null
const surfaceMesh = sceneRegistry.nodes.get(surfaceItem.id)
if (!surfaceMesh) return null
const worldPos = new Vector3(event.position[0], event.position[1], event.position[2])
const localPos = surfaceMesh.worldToLocal(worldPos)
const x = snapToGrid(localPos.x, ourDims[0])
const z = snapToGrid(localPos.z, ourDims[2])
const y = surfaceItem.asset.surface.height * surfaceItem.scale[1]
const worldSnapped = surfaceMesh.localToWorld(new Vector3(x, y, z))
return {
stateUpdate: { surface: 'item-surface', surfaceItemId: surfaceItem.id },
nodeUpdate: { position: [x, y, z], parentId: surfaceItem.id },
cursorRotationY: 0,
gridPosition: [x, y, z],
cursorPosition: [worldSnapped.x, worldSnapped.y, worldSnapped.z],
stopPropagation: true,
}
},
/**
* Handle item:move — update position while on an item surface.
*/
move(ctx: PlacementContext, event: ItemEvent): PlacementResult | null {
if (ctx.state.surface !== 'item-surface') 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
if (!surfaceItem?.asset.surface) return null
const surfaceMesh = sceneRegistry.nodes.get(ctx.state.surfaceItemId)
if (!surfaceMesh) return null
const ourDims = getScaledDimensions(ctx.draftItem)
const worldPos = new Vector3(event.position[0], event.position[1], event.position[2])
const localPos = surfaceMesh.worldToLocal(worldPos)
const x = snapToGrid(localPos.x, ourDims[0])
const z = snapToGrid(localPos.z, ourDims[2])
const y = surfaceItem.asset.surface.height * surfaceItem.scale[1]
const worldSnapped = surfaceMesh.localToWorld(new Vector3(x, y, z))
return {
gridPosition: [x, y, z],
cursorPosition: [worldSnapped.x, worldSnapped.y, worldSnapped.z],
cursorRotationY: 0,
nodeUpdate: { position: [x, y, z] },
stopPropagation: true,
dirtyNodeId: null,
}
},
/**
* Handle item:click — commit placement on item surface.
*/
click(ctx: PlacementContext, _event: ItemEvent): CommitResult | null {
if (ctx.state.surface !== 'item-surface') return null
if (!ctx.draftItem || !ctx.state.surfaceItemId) return null
return {
nodeUpdate: {
position: [ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z],
parentId: ctx.state.surfaceItemId,
metadata: stripTransient(ctx.draftItem.metadata),
},
stopPropagation: true,
dirtyNodeId: null,
}
},
}
// ============================================================================
// VALIDATION
// ============================================================================
/**
* Unified validation: check if the current draft item can be placed at its current position.
* 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
// Item surface: valid if we entered (size check was in enter)
if (ctx.state.surface === 'item-surface') {
return ctx.state.surfaceItemId !== null
}
const attachTo = ctx.draftItem.asset.attachTo
if (attachTo === 'ceiling') {
if (ctx.state.surface !== 'ceiling' || !ctx.state.ceilingId) return false
return validators.canPlaceOnCeiling(
ctx.state.ceilingId as CeilingNode['id'],
[ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z],
getScaledDimensions(ctx.draftItem),
ctx.draftItem.rotation,
[ctx.draftItem.id],
).valid
}
if (attachTo === 'wall' || attachTo === 'wall-side') {
if (ctx.state.surface !== 'wall' || !ctx.state.wallId) return false
return validators.canPlaceOnWall(
ctx.levelId,
ctx.state.wallId as WallNode['id'],
ctx.gridPosition.x,
ctx.gridPosition.y,
getScaledDimensions(ctx.draftItem),
attachTo,
ctx.draftItem.side,
[ctx.draftItem.id],
).valid
}
// Floor (no attachTo)
return validators.canPlaceOnFloor(
ctx.levelId,
[ctx.gridPosition.x, 0, ctx.gridPosition.z],
getScaledDimensions(ctx.draftItem),
[0, 0, 0],
[ctx.draftItem.id],
).valid
}
@@ -1,110 +0,0 @@
import type { AnyNode, AssetInput, CeilingNode, ItemNode, LevelNode, WallNode } from '@pascal-app/core'
import type { Vector3 } from 'three'
// ============================================================================
// PLACEMENT STATE
// ============================================================================
export type SurfaceType = 'floor' | 'wall' | 'ceiling' | 'item-surface'
/**
* Tracks which surface the draft item is currently on.
* Replaces the scattered isOnWall, isOnCeiling refs and currentWallId, currentCeilingId variables.
*/
export interface PlacementState {
surface: SurfaceType
wallId: string | null
ceilingId: string | null
surfaceItemId: string | null
}
// ============================================================================
// STRATEGY CONTEXT
// ============================================================================
/**
* Read-only snapshot passed to every strategy call.
*/
export interface PlacementContext {
asset: AssetInput
levelId: LevelNode['id'] | null
draftItem: ItemNode | null
gridPosition: Vector3
state: PlacementState
}
// ============================================================================
// STRATEGY RESULTS
// ============================================================================
/**
* Returned by strategy move handlers.
*/
export interface PlacementResult {
gridPosition: [number, number, number]
cursorPosition: [number, number, number]
cursorRotationY: number
nodeUpdate: Partial<ItemNode> | null
stopPropagation: boolean
dirtyNodeId: AnyNode['id'] | null
}
/**
* Returned by enter/leave handlers (surface transitions).
*/
export interface TransitionResult {
stateUpdate: Partial<PlacementState>
nodeUpdate: Partial<ItemNode>
gridPosition: [number, number, number]
cursorPosition: [number, number, number]
cursorRotationY: number
stopPropagation: boolean
}
/**
* Returned by click handlers (commit placement).
*/
export interface CommitResult {
nodeUpdate: Partial<ItemNode>
stopPropagation: boolean
dirtyNodeId: AnyNode['id'] | null
}
// ============================================================================
// SPATIAL VALIDATORS
// ============================================================================
/**
* Type for the useSpatialQuery() return value.
*/
export interface SpatialValidators {
canPlaceOnFloor: (
levelId: LevelNode['id'],
position: [number, number, number],
dimensions: [number, number, number],
rotation: [number, number, number],
ignoreIds?: string[],
) => { valid: boolean }
canPlaceOnWall: (
levelId: LevelNode['id'],
wallId: WallNode['id'],
localX: number,
localY: number,
dimensions: [number, number, number],
attachType: 'wall' | 'wall-side',
side?: 'front' | 'back',
ignoreIds?: string[],
) => { valid: boolean; adjustedY?: number; wasAdjusted?: boolean }
canPlaceOnCeiling: (
ceilingId: CeilingNode['id'],
position: [number, number, number],
dimensions: [number, number, number],
rotation: [number, number, number],
ignoreIds?: string[],
) => { valid: boolean }
}
/**
* Resolver function type for finding a node's level.
*/
export type LevelResolver = (node: AnyNode, nodes: Record<string, AnyNode>) => string
@@ -1,206 +0,0 @@
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'
import { stripTransient } from './placement-math'
interface OriginalState {
position: [number, number, number]
rotation: [number, number, number]
side: ItemNode['side']
parentId: string | null
metadata: ItemNode['metadata']
}
export interface DraftNodeHandle {
/** Current draft item, or null */
readonly current: ItemNode | null
/** 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
/** 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. */
commit: (finalUpdate: Partial<ItemNode>) => string | null
/** Destroy the current draft. Create mode: delete node. Move mode: restore original state. */
destroy: () => void
}
/**
* Hook that manages the lifecycle of a transient (draft) item node.
* Handles temporal pause/resume for undo/redo isolation.
*
* Supports two modes:
* - Create mode (via `create()`): draft is a new transient node. Commit = delete+recreate (undo removes node).
* - Move mode (via `adopt()`): draft is an existing node. Commit = update in place (undo reverts position).
*/
export function useDraftNode(): DraftNodeHandle {
const draftRef = useRef<ItemNode | null>(null)
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 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
}, [])
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>
: {}
originalStateRef.current = {
position: [...node.position] as [number, number, number],
rotation: [...node.rotation] as [number, number, number],
side: node.side,
parentId: node.parentId,
metadata: node.metadata,
}
draftRef.current = node
adoptedRef.current = true
// Mark as transient so it renders as a draft
useScene.getState().updateNode(node.id, {
metadata: { ...meta, isTransient: true },
})
}, [])
const commit = useCallback((finalUpdate: Partial<ItemNode>): string | null => {
const draft = draftRef.current
if (!draft) return null
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 original = originalStateRef.current!
// Restore original state while paused — so the undo baseline is clean
useScene.getState().updateNode(draft.id, {
position: original.position,
rotation: original.rotation,
side: original.side,
parentId: original.parentId,
metadata: original.metadata,
})
// Resume → tracked update (undo reverts to original)
useScene.temporal.getState().resume()
useScene.getState().updateNode(draft.id, {
position: updateProps.position ?? draft.position,
rotation: updateProps.rotation ?? draft.rotation,
side: updateProps.side ?? draft.side,
metadata: updateProps.metadata ?? stripTransient(draft.metadata),
parentId: parentId as string,
})
useScene.temporal.getState().pause()
const id = draft.id
draftRef.current = null
adoptedRef.current = false
originalStateRef.current = null
return id
}
// Create mode: delete draft (paused), resume, create fresh node (tracked), re-pause
const { parentId: newParentId, ...updateProps } = finalUpdate
const parentId = (newParentId ?? useViewer.getState().selection.levelId) as AnyNodeId
if (!parentId) return null
// Delete draft while paused (invisible to undo)
useScene.getState().deleteNode(draft.id)
draftRef.current = null
// Briefly resume → create fresh node (the single undoable action)
useScene.temporal.getState().resume()
const finalNode = ItemNode.parse({
name: draft.name,
asset: draft.asset,
position: updateProps.position ?? draft.position,
rotation: updateProps.rotation ?? draft.rotation,
side: updateProps.side ?? draft.side,
metadata: updateProps.metadata ?? stripTransient(draft.metadata),
})
useScene.getState().createNode(finalNode, parentId)
// Re-pause for next draft cycle
useScene.temporal.getState().pause()
adoptedRef.current = false
originalStateRef.current = null
return finalNode.id
}, [])
const destroy = useCallback(() => {
if (!draftRef.current) return
if (adoptedRef.current && originalStateRef.current) {
// Move mode: restore original state instead of deleting
const original = originalStateRef.current
const id = draftRef.current.id
useScene.getState().updateNode(id, {
position: original.position,
rotation: original.rotation,
side: original.side,
parentId: original.parentId,
metadata: original.metadata,
})
// Also reset the Three.js mesh directly — the store update triggers a React
// re-render but the mesh position was mutated by useFrame and may not reset
// until the next render cycle, leaving a visual glitch.
const mesh = sceneRegistry.nodes.get(id as AnyNodeId)
if (mesh) {
mesh.position.set(original.position[0], original.position[1], original.position[2])
mesh.rotation.y = original.rotation[1] ?? 0
mesh.visible = true
}
} else {
// Create mode: delete the transient node
useScene.getState().deleteNode(draftRef.current.id)
}
draftRef.current = null
adoptedRef.current = false
originalStateRef.current = null
}, [])
return useMemo(
() => ({
get current() {
return draftRef.current
},
get isAdopted() {
return adoptedRef.current
},
create,
adopt,
commit,
destroy,
}),
[create, adopt, commit, destroy],
)
}
@@ -1,769 +0,0 @@
import type { AssetInput } from '@pascal-app/core'
import {
type AnyNodeId,
type CeilingEvent,
emitter,
getScaledDimensions,
type GridEvent,
type ItemEvent,
resolveLevelId,
sceneRegistry,
spatialGridManager,
useScene,
useSpatialQuery,
type WallEvent,
type WallNode,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useFrame } from '@react-three/fiber'
import { useEffect, useRef } from 'react'
import {
BoxGeometry,
EdgesGeometry,
Euler,
type Group,
type LineSegments,
type Mesh,
PlaneGeometry,
Quaternion,
Vector3,
} from 'three'
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 type { PlacementState, TransitionResult } from './placement-types'
import type { DraftNodeHandle } from './use-draft-node'
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)
linewidth: 3,
depthTest: false,
depthWrite: false,
})
const basePlaneMaterial = new MeshBasicNodeMaterial({
color: 0xef4444, // red-500 (invalid)
transparent: true,
depthTest: false,
depthWrite: false,
})
// Create radial opacity: transparent in center, opaque at edges
const center = vec2(0.5, 0.5)
const dist = distance(uv(), center)
const radialOpacity = smoothstep(0, 0.7, dist).mul(0.6)
basePlaneMaterial.opacityNode = radialOpacity
export interface PlacementCoordinatorConfig {
asset: AssetInput
draftNode: DraftNodeHandle
initDraft: (gridPosition: Vector3) => void
onCommitted: () => boolean
onCancel?: () => void
initialState?: PlacementState
/** Scale to use when lazily creating a draft (e.g. for wall/ceiling duplicates). Defaults to [1,1,1]. */
defaultScale?: [number, number, number]
}
export function usePlacementCoordinator(config: PlacementCoordinatorConfig): React.ReactNode {
const cursorGroupRef = useRef<Group>(null!)
const edgesRef = useRef<LineSegments>(null!)
const basePlaneRef = useRef<Mesh>(null!)
const gridPosition = useRef(new Vector3(0, 0, 0))
const placementState = useRef<PlacementState>(
config.initialState ?? { surface: 'floor', wallId: null, ceilingId: null, surfaceItemId: null },
)
const shiftFreeRef = useRef(false)
// Store config callbacks in refs to avoid re-running effect when they change
const configRef = useRef(config)
configRef.current = config
const { canPlaceOnFloor, canPlaceOnWall, canPlaceOnCeiling } = useSpatialQuery()
const { asset, draftNode } = config
useEffect(() => {
useScene.temporal.getState().pause()
const validators = { canPlaceOnFloor, canPlaceOnWall, canPlaceOnCeiling }
// Reset placement state
placementState.current = configRef.current.initialState ?? {
surface: 'floor',
wallId: null,
ceilingId: null,
surfaceItemId: null,
}
// ---- Helpers ----
const getContext = () => ({
asset,
levelId: useViewer.getState().selection.levelId,
draftItem: draftNode.current,
gridPosition: gridPosition.current,
state: { ...placementState.current },
})
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
edgeMaterial.color.setHex(color)
basePlaneMaterial.color.setHex(color)
return placeable
}
const applyTransition = (result: TransitionResult) => {
Object.assign(placementState.current, result.stateUpdate)
gridPosition.current.set(...result.gridPosition)
cursorGroupRef.current.position.set(...result.cursorPosition)
cursorGroupRef.current.rotation.y = result.cursorRotationY
const draft = draftNode.current
if (draft) {
// Update ref for validation — no store update during drag
Object.assign(draft, result.nodeUpdate)
}
revalidate()
}
const ensureDraft = (result: TransitionResult) => {
gridPosition.current.set(...result.gridPosition)
cursorGroupRef.current.position.set(...result.cursorPosition)
cursorGroupRef.current.rotation.y = result.cursorRotationY
draftNode.create(gridPosition.current, asset, [0, result.cursorRotationY, 0], configRef.current.defaultScale)
const draft = draftNode.current
if (draft) {
Object.assign(draft, result.nodeUpdate)
// One-time setup: put node in the right parent so it renders correctly
useScene.getState().updateNode(draft.id, result.nodeUpdate)
}
if (!revalidate()) {
draftNode.destroy()
}
}
// ---- Init draft ----
configRef.current.initDraft(gridPosition.current)
// Sync cursor to the draft mesh's world position and rotation
if (draftNode.current) {
const mesh = sceneRegistry.nodes.get(draftNode.current.id)
if (mesh) {
mesh.getWorldPosition(cursorGroupRef.current.position)
// Extract world Y rotation (handles wall-parented items correctly)
const q = new Quaternion()
mesh.getWorldQuaternion(q)
cursorGroupRef.current.rotation.y = new Euler().setFromQuaternion(q, 'YXZ').y
} else {
cursorGroupRef.current.position.copy(gridPosition.current)
cursorGroupRef.current.rotation.y = draftNode.current.rotation[1] ?? 0
}
}
revalidate()
// ---- Floor Handlers ----
let previousGridPos: [number, number, number] | null = null
const onGridMove = (event: GridEvent) => {
const result = floorStrategy.move(getContext(), event)
if (!result) return
// Play snap sound when grid position changes
if (
previousGridPos &&
(result.gridPosition[0] !== previousGridPos[0] ||
result.gridPosition[2] !== previousGridPos[2])
) {
sfxEmitter.emit('sfx:grid-snap')
}
previousGridPos = [...result.gridPosition]
gridPosition.current.set(...result.gridPosition)
// Only update X and Z for cursor - useFrame will handle Y (slab elevation)
cursorGroupRef.current.position.x = result.cursorPosition[0]
cursorGroupRef.current.position.z = result.cursorPosition[2]
const draft = draftNode.current
if (draft) draft.position = result.gridPosition
revalidate()
}
const onGridClick = (event: GridEvent) => {
const result = floorStrategy.click(getContext(), event, getActiveValidators())
if (!result) return
// Preserve cursor rotation for the next draft
const currentRotation: [number, number, number] = [0, cursorGroupRef.current.rotation.y, 0]
draftNode.commit(result.nodeUpdate)
if (configRef.current.onCommitted()) {
draftNode.create(gridPosition.current, asset, currentRotation)
revalidate()
}
}
// ---- Wall Handlers ----
const onWallEnter = (event: WallEvent) => {
const nodes = useScene.getState().nodes
const result = wallStrategy.enter(getContext(), event, resolveLevelId, nodes, getActiveValidators())
if (!result) return
event.stopPropagation()
applyTransition(result)
if (!draftNode.current) {
ensureDraft(result)
} else if (result.nodeUpdate.parentId) {
// Existing draft (move mode): reparent to new wall
useScene.getState().updateNode(draftNode.current.id, result.nodeUpdate)
if (result.stateUpdate.wallId) {
useScene.getState().dirtyNodes.add(result.stateUpdate.wallId as AnyNodeId)
}
}
}
const onWallMove = (event: WallEvent) => {
const ctx = getContext()
if (ctx.state.surface !== 'wall') {
const nodes = useScene.getState().nodes
const enterResult = wallStrategy.enter(ctx, event, resolveLevelId, nodes, getActiveValidators())
if (!enterResult) return
event.stopPropagation()
applyTransition(enterResult)
if (draftNode.current && enterResult.nodeUpdate.parentId) {
useScene.getState().updateNode(draftNode.current.id, enterResult.nodeUpdate)
if (enterResult.stateUpdate.wallId) {
useScene.getState().dirtyNodes.add(enterResult.stateUpdate.wallId as AnyNodeId)
}
}
return
}
if (!draftNode.current) {
const nodes = useScene.getState().nodes
const setup = wallStrategy.enter(getContext(), event, resolveLevelId, nodes, getActiveValidators())
if (!setup) return
event.stopPropagation()
ensureDraft(setup)
return
}
const result = wallStrategy.move(ctx, event, getActiveValidators())
if (!result) return
event.stopPropagation()
const posChanged =
gridPosition.current.x !== result.gridPosition[0] ||
gridPosition.current.y !== result.gridPosition[1] ||
gridPosition.current.z !== result.gridPosition[2]
// Play snap sound when grid position changes
if (posChanged) {
sfxEmitter.emit('sfx:grid-snap')
}
gridPosition.current.set(...result.gridPosition)
cursorGroupRef.current.position.set(...result.cursorPosition)
cursorGroupRef.current.rotation.y = result.cursorRotationY
const draft = draftNode.current
if (draft && result.nodeUpdate) {
if ('side' in result.nodeUpdate) draft.side = result.nodeUpdate.side
if ('rotation' in result.nodeUpdate)
draft.rotation = result.nodeUpdate.rotation as [number, number, number]
}
const placeable = revalidate()
if (draft && placeable) {
draft.position = result.gridPosition
const mesh = sceneRegistry.nodes.get(draft.id)
if (mesh) {
mesh.position.copy(gridPosition.current)
const rot = result.nodeUpdate?.rotation
if (rot) mesh.rotation.y = rot[1]
// Push wall-side items out by half the parent wall's thickness
if (asset.attachTo === 'wall-side' && placementState.current.wallId) {
const parentWall = useScene.getState().nodes[placementState.current.wallId as AnyNodeId]
if (parentWall?.type === 'wall') {
const wallThickness = (parentWall as WallNode).thickness ?? 0.1
mesh.position.z = (wallThickness / 2) * (draft.side === 'front' ? 1 : -1)
}
}
}
// Mark parent wall dirty so it rebuilds geometry — only when position changed
if (result.dirtyNodeId && posChanged) {
useScene.getState().dirtyNodes.add(result.dirtyNodeId)
}
}
}
const onWallClick = (event: WallEvent) => {
const result = wallStrategy.click(getContext(), event, getActiveValidators())
if (!result) return
event.stopPropagation()
draftNode.commit(result.nodeUpdate)
if (result.dirtyNodeId) {
useScene.getState().dirtyNodes.add(result.dirtyNodeId)
}
if (configRef.current.onCommitted()) {
const nodes = useScene.getState().nodes
const enterResult = wallStrategy.enter(getContext(), event, resolveLevelId, nodes, validators)
if (enterResult) {
applyTransition(enterResult)
} else {
revalidate()
}
}
}
const onWallLeave = (event: WallEvent) => {
const result = wallStrategy.leave(getContext())
if (!result) return
event.stopPropagation()
if (asset.attachTo) {
if (draftNode.isAdopted) {
// Move mode: keep draft alive, reparent to level
const oldWallId = placementState.current.wallId
applyTransition(result)
const draft = draftNode.current
if (draft) {
useScene
.getState()
.updateNode(draft.id, { parentId: result.nodeUpdate.parentId as string })
}
if (oldWallId) {
useScene.getState().dirtyNodes.add(oldWallId as AnyNodeId)
}
} else {
// Create mode: destroy transient and reset state
draftNode.destroy()
Object.assign(placementState.current, result.stateUpdate)
}
} else {
applyTransition(result)
}
}
// ---- Item Surface Handlers ----
const onItemEnter = (event: ItemEvent) => {
if (event.node.id === draftNode.current?.id) return
const result = itemSurfaceStrategy.enter(getContext(), event)
if (!result) return
event.stopPropagation()
applyTransition(result)
if (!draftNode.current) {
ensureDraft(result)
} else if (result.nodeUpdate.parentId) {
// Existing draft (move mode): reparent to surface item
useScene.getState().updateNode(draftNode.current.id, result.nodeUpdate)
}
}
const onItemMove = (event: ItemEvent) => {
if (event.node.id === draftNode.current?.id) return
const ctx = getContext()
if (ctx.state.surface !== 'item-surface') {
// Try entering surface mode
const enterResult = itemSurfaceStrategy.enter(ctx, event)
if (!enterResult) return
event.stopPropagation()
applyTransition(enterResult)
if (draftNode.current && enterResult.nodeUpdate.parentId) {
useScene.getState().updateNode(draftNode.current.id, enterResult.nodeUpdate)
}
return
}
if (!draftNode.current) {
const enterResult = itemSurfaceStrategy.enter(getContext(), event)
if (!enterResult) return
event.stopPropagation()
ensureDraft(enterResult)
return
}
const result = itemSurfaceStrategy.move(ctx, event)
if (!result) return
event.stopPropagation()
gridPosition.current.set(...result.gridPosition)
cursorGroupRef.current.position.set(...result.cursorPosition)
cursorGroupRef.current.rotation.y = result.cursorRotationY
const draft = draftNode.current
if (draft) {
draft.position = result.gridPosition
const mesh = sceneRegistry.nodes.get(draft.id)
if (mesh) mesh.position.set(...result.gridPosition)
}
revalidate()
}
const onItemLeave = (event: ItemEvent) => {
if (event.node.id === draftNode.current?.id) return
if (placementState.current.surface !== 'item-surface') return
event.stopPropagation()
// Transition back to floor using event world position
const wx = Math.round(event.position[0] * 2) / 2
const wz = Math.round(event.position[2] * 2) / 2
const floorPos: [number, number, number] = [wx, 0, wz]
Object.assign(placementState.current, { surface: 'floor', surfaceItemId: null })
gridPosition.current.set(wx, 0, wz)
cursorGroupRef.current.position.set(wx, event.position[1], wz)
const draft = draftNode.current
if (draft) {
draft.position = floorPos
useScene.getState().updateNode(draft.id, {
parentId: useViewer.getState().selection.levelId as string,
position: floorPos,
})
}
revalidate()
}
const onItemClick = (event: ItemEvent) => {
if (event.node.id === draftNode.current?.id) return
const result = itemSurfaceStrategy.click(getContext(), event)
if (!result) return
event.stopPropagation()
draftNode.commit(result.nodeUpdate)
if (configRef.current.onCommitted()) {
// Try to set up next draft on the same surface
const enterResult = itemSurfaceStrategy.enter(getContext(), event)
if (enterResult) {
applyTransition(enterResult)
} else {
revalidate()
}
}
}
// ---- Ceiling Handlers ----
const onCeilingEnter = (event: CeilingEvent) => {
const nodes = useScene.getState().nodes
const result = ceilingStrategy.enter(getContext(), event, resolveLevelId, nodes)
if (!result) return
event.stopPropagation()
applyTransition(result)
if (!draftNode.current) {
ensureDraft(result)
} else if (result.nodeUpdate.parentId) {
// Existing draft (move mode): reparent to new ceiling
useScene.getState().updateNode(draftNode.current.id, result.nodeUpdate)
if (result.stateUpdate.ceilingId) {
useScene.getState().dirtyNodes.add(result.stateUpdate.ceilingId as AnyNodeId)
}
}
}
const onCeilingMove = (event: CeilingEvent) => {
if (!draftNode.current && placementState.current.surface === 'ceiling') {
const nodes = useScene.getState().nodes
const setup = ceilingStrategy.enter(getContext(), event, resolveLevelId, nodes)
if (!setup) return
event.stopPropagation()
ensureDraft(setup)
return
}
const result = ceilingStrategy.move(getContext(), event)
if (!result) return
event.stopPropagation()
// Play snap sound when grid position changes
const posChanged =
gridPosition.current.x !== result.gridPosition[0] ||
gridPosition.current.y !== result.gridPosition[1] ||
gridPosition.current.z !== result.gridPosition[2]
if (posChanged) {
sfxEmitter.emit('sfx:grid-snap')
}
gridPosition.current.set(...result.gridPosition)
cursorGroupRef.current.position.set(...result.cursorPosition)
revalidate()
const draft = draftNode.current
if (draft) {
draft.position = result.gridPosition
const mesh = sceneRegistry.nodes.get(draft.id)
if (mesh) mesh.position.copy(gridPosition.current)
}
}
const onCeilingClick = (event: CeilingEvent) => {
const result = ceilingStrategy.click(getContext(), event, getActiveValidators())
if (!result) return
event.stopPropagation()
draftNode.commit(result.nodeUpdate)
if (configRef.current.onCommitted()) {
const nodes = useScene.getState().nodes
const enterResult = ceilingStrategy.enter(getContext(), event, resolveLevelId, nodes)
if (enterResult) {
applyTransition(enterResult)
} else {
revalidate()
}
}
}
const onCeilingLeave = (event: CeilingEvent) => {
const result = ceilingStrategy.leave(getContext())
if (!result) return
event.stopPropagation()
if (asset.attachTo) {
if (draftNode.isAdopted) {
// Move mode: keep draft alive, reparent to level
const oldCeilingId = placementState.current.ceilingId
applyTransition(result)
const draft = draftNode.current
if (draft) {
useScene
.getState()
.updateNode(draft.id, { parentId: result.nodeUpdate.parentId as string })
}
if (oldCeilingId) {
useScene.getState().dirtyNodes.add(oldCeilingId as AnyNodeId)
}
} else {
// Create mode: destroy transient and reset state
draftNode.destroy()
Object.assign(placementState.current, result.stateUpdate)
}
} else {
applyTransition(result)
}
}
// ---- Keyboard rotation ----
const ROTATION_STEP = Math.PI / 2
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Shift') {
shiftFreeRef.current = true
revalidate()
return
}
const draft = draftNode.current
if (!draft) return
let rotationDelta = 0
if (event.key === 'r' || event.key === 'R') rotationDelta = ROTATION_STEP
else if (event.key === 't' || event.key === 'T') rotationDelta = -ROTATION_STEP
if (rotationDelta !== 0) {
event.preventDefault()
sfxEmitter.emit('sfx:item-rotate')
const currentRotation = draft.rotation
const newRotationY = (currentRotation[1] ?? 0) + rotationDelta
draft.rotation = [currentRotation[0], newRotationY, currentRotation[2]]
// Ref + cursor mesh + item mesh — no store update during drag
cursorGroupRef.current.rotation.y = newRotationY
const mesh = sceneRegistry.nodes.get(draft.id)
if (mesh) mesh.rotation.y = newRotationY
revalidate()
}
}
const onKeyUp = (event: KeyboardEvent) => {
if (event.key === 'Shift') {
shiftFreeRef.current = false
revalidate()
}
}
window.addEventListener('keydown', onKeyDown)
window.addEventListener('keyup', onKeyUp)
// ---- tool:cancel (Escape / programmatic) ----
const onCancel = () => {
if (configRef.current.onCancel) {
configRef.current.onCancel()
}
}
emitter.on('tool:cancel', onCancel)
// ---- Right-click cancel ----
const onContextMenu = (event: MouseEvent) => {
if (configRef.current.onCancel) {
event.preventDefault()
configRef.current.onCancel()
}
}
window.addEventListener('contextmenu', onContextMenu)
// ---- Bounding box geometry ----
const draft = draftNode.current
const dims = draft ? getScaledDimensions(draft) : (asset.dimensions ?? DEFAULT_DIMENSIONS)
const boxGeometry = new BoxGeometry(dims[0], dims[1], dims[2])
boxGeometry.translate(0, dims[1] / 2, 0)
const edgesGeometry = new EdgesGeometry(boxGeometry)
edgesRef.current.geometry = edgesGeometry
// ---- Subscribe ----
emitter.on('grid:move', onGridMove)
emitter.on('grid:click', onGridClick)
emitter.on('item:enter', onItemEnter)
emitter.on('item:move', onItemMove)
emitter.on('item:leave', onItemLeave)
emitter.on('item:click', onItemClick)
emitter.on('wall:enter', onWallEnter)
emitter.on('wall:move', onWallMove)
emitter.on('wall:click', onWallClick)
emitter.on('wall:leave', onWallLeave)
emitter.on('ceiling:enter', onCeilingEnter)
emitter.on('ceiling:move', onCeilingMove)
emitter.on('ceiling:click', onCeilingClick)
emitter.on('ceiling:leave', onCeilingLeave)
return () => {
draftNode.destroy()
useScene.temporal.getState().resume()
emitter.off('grid:move', onGridMove)
emitter.off('grid:click', onGridClick)
emitter.off('item:enter', onItemEnter)
emitter.off('item:move', onItemMove)
emitter.off('item:leave', onItemLeave)
emitter.off('item:click', onItemClick)
emitter.off('wall:enter', onWallEnter)
emitter.off('wall:move', onWallMove)
emitter.off('wall:click', onWallClick)
emitter.off('wall:leave', onWallLeave)
emitter.off('ceiling:enter', onCeilingEnter)
emitter.off('ceiling:move', onCeilingMove)
emitter.off('ceiling:click', onCeilingClick)
emitter.off('ceiling:leave', onCeilingLeave)
emitter.off('tool:cancel', onCancel)
window.removeEventListener('keydown', onKeyDown)
window.removeEventListener('keyup', onKeyUp)
window.removeEventListener('contextmenu', onContextMenu)
}
}, [asset, canPlaceOnFloor, canPlaceOnWall, canPlaceOnCeiling, draftNode])
// Reparent floor draft to the new level when the user switches levels mid-placement.
// Wall/ceiling items are managed by their own surface entry events (ensureDraft / reparent).
const viewerLevelId = useViewer((s) => s.selection.levelId)
useEffect(() => {
const draft = draftNode.current
if (!draft || !viewerLevelId || asset.attachTo) return
if (draft.parentId === viewerLevelId) return
draft.parentId = viewerLevelId
useScene.getState().updateNode(draft.id as AnyNodeId, { parentId: viewerLevelId })
}, [viewerLevelId, draftNode, asset])
useFrame((_, delta) => {
if (!draftNode.current) return
const mesh = sceneRegistry.nodes.get(draftNode.current.id)
if (!mesh) return
// Hide wall/ceiling-attached items when between surfaces (only cursor visible)
if (asset.attachTo && placementState.current.surface === 'floor') {
mesh.visible = false
return
}
mesh.visible = true
if (placementState.current.surface === 'floor') {
const distance = mesh.position.distanceToSquared(gridPosition.current)
if (distance > 1) {
mesh.position.copy(gridPosition.current)
} else {
mesh.position.lerp(gridPosition.current, delta * 20)
}
// Adjust Y for slab elevation (floor items on top of slabs)
if (!asset.attachTo) {
const nodes = useScene.getState().nodes
const levelId = resolveLevelId(draftNode.current, nodes)
const slabElevation = spatialGridManager.getSlabElevationForItem(
levelId,
[gridPosition.current.x, gridPosition.current.y, gridPosition.current.z],
getScaledDimensions(draftNode.current),
draftNode.current.rotation,
)
mesh.position.y = slabElevation
// Cursor group is at the world root (not inside a level group), so add the
// level group's current world Y to convert from level-local to world space.
const levelGroup = sceneRegistry.nodes.get(levelId as AnyNodeId)
cursorGroupRef.current.position.y = slabElevation + (levelGroup?.position.y ?? 0)
}
}
})
const initialDraft = draftNode.current
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)
// Base plane geometry (colored rectangle on the ground)
const basePlaneGeometry = new PlaneGeometry(dims[0], dims[2])
basePlaneGeometry.rotateX(-Math.PI / 2) // Make it horizontal
basePlaneGeometry.translate(0, 0.01, 0) // Slightly above ground to avoid z-fighting
return (
<group ref={cursorGroupRef}>
<lineSegments ref={edgesRef} material={edgeMaterial} layers={EDITOR_LAYER}>
<edgesGeometry args={[initialBoxGeometry]} />
</lineSegments>
<mesh ref={basePlaneRef} geometry={basePlaneGeometry} material={basePlaneMaterial} layers={EDITOR_LAYER} />
</group>
)
}
@@ -1,248 +0,0 @@
import {
type AnyNode,
emitter,
type GridEvent,
type LevelNode,
RoofNode,
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, Vector3 } from 'three'
import { EDITOR_LAYER } from '@/lib/constants'
import { sfxEmitter } from '@/lib/sfx-bus'
import useEditor from '@/store/use-editor'
import { CursorSphere } from '../shared/cursor-sphere'
// Default roof dimensions
const DEFAULT_HEIGHT = 1.5
const CEILING_HEIGHT = 2.52
const GRID_OFFSET = 0.02
/**
* Creates a roof with the given corners
*/
const commitRoofPlacement = (
levelId: LevelNode['id'],
corner1: [number, number, number],
corner2: [number, number, number],
): RoofNode['id'] => {
const { createNode, nodes } = useScene.getState()
// Calculate center position and dimensions from corners
const centerX = (corner1[0] + corner2[0]) / 2
const centerZ = (corner1[2] + corner2[2]) / 2
const length = Math.abs(corner2[0] - corner1[0])
const width = Math.abs(corner2[2] - corner1[2])
// Split width evenly between left and right slopes
const slopeWidth = Math.max(width / 2, 0.5)
// Count existing roofs for naming
const roofCount = Object.values(nodes).filter((n) => n.type === 'roof').length
const name = `Roof ${roofCount + 1}`
const roof = RoofNode.parse({
name,
position: [centerX, 0, centerZ], // Y is always 0
length: Math.max(length, 0.5),
height: DEFAULT_HEIGHT,
leftWidth: slopeWidth,
rightWidth: slopeWidth,
})
createNode(roof, levelId)
sfxEmitter.emit('sfx:structure-build')
return roof.id
}
type PreviewState = {
corner1: [number, number, number] | null
cursorPosition: [number, number, number]
levelY: number
}
export const RoofTool: React.FC = () => {
const cursorRef = useRef<Group>(null)
const outlineRef = useRef<Line>(null!)
const currentLevelId = useViewer((state) => state.selection.levelId)
const setSelection = useViewer((state) => state.setSelection)
const setTool = useEditor((state) => state.setTool)
const setMode = useEditor((state) => state.setMode)
const corner1Ref = useRef<[number, number, number] | null>(null)
const previousGridPosRef = useRef<[number, number] | null>(null)
const [preview, setPreview] = useState<PreviewState>({
corner1: null,
cursorPosition: [0, 0, 0],
levelY: 0,
})
useEffect(() => {
if (!currentLevelId) return
// Initialize outline geometry
outlineRef.current.geometry = new BufferGeometry()
const updateOutline = (
corner1: [number, number, number],
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]),
new Vector3(corner2[0], gridY, corner2[2]),
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
}
const onGridMove = (event: GridEvent) => {
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
const y = event.position[1]
const cursorPosition: [number, number, number] = [gridX, y, gridZ]
// Update cursors
const gridY = y + GRID_OFFSET
cursorRef.current.position.set(gridX, gridY, gridZ)
// Play snap sound when grid position changes (only when placing)
if (
corner1Ref.current &&
previousGridPosRef.current &&
(gridX !== previousGridPosRef.current[0] || gridZ !== previousGridPosRef.current[1])
) {
sfxEmitter.emit('sfx:grid-snap')
}
previousGridPosRef.current = [gridX, gridZ]
setPreview({
corner1: corner1Ref.current,
cursorPosition,
levelY: y,
})
// Update outline if we have first corner
if (corner1Ref.current) {
updateOutline(corner1Ref.current, cursorPosition)
}
}
const onGridClick = (event: GridEvent) => {
if (!currentLevelId) return
const gridX = Math.round(event.position[0] * 2) / 2
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 {
// Second click - create the roof
const roofId = commitRoofPlacement(currentLevelId, corner1Ref.current, [gridX, y, gridZ])
// Auto-select the newly created roof
setSelection({ selectedIds: [roofId as AnyNode['id']] })
// Reset state
corner1Ref.current = null
outlineRef.current.visible = false
}
}
const onCancel = () => {
if (corner1Ref.current) {
corner1Ref.current = null
outlineRef.current.visible = false
setPreview((prev) => ({ ...prev, corner1: null }))
}
}
// Subscribe to events
emitter.on('grid:move', onGridMove)
emitter.on('grid:click', onGridClick)
emitter.on('tool:cancel', onCancel)
return () => {
emitter.off('grid:move', onGridMove)
emitter.off('grid:click', onGridClick)
emitter.off('tool:cancel', onCancel)
// Reset state on unmount
corner1Ref.current = null
}
}, [currentLevelId, setTool, setSelection, setMode])
const { corner1, cursorPosition, levelY } = preview
// Calculate preview dimensions for display
const previewDimensions = useMemo(() => {
if (!corner1) return null
const length = Math.abs(cursorPosition[0] - corner1[0])
const width = Math.abs(cursorPosition[2] - corner1[2])
const centerX = (corner1[0] + cursorPosition[0]) / 2
const centerZ = (corner1[2] + cursorPosition[2]) / 2
return { length, width, centerX, centerZ }
}, [corner1, cursorPosition])
return (
<group>
{/* Cursor at ground height */}
<CursorSphere ref={cursorRef} />
{/* Outline showing rectangle being drawn (Ground) */}
{/* @ts-ignore */}
<line ref={outlineRef} frustumCulled={false} renderOrder={1} visible={false} layers={EDITOR_LAYER}>
<bufferGeometry />
<lineBasicNodeMaterial color="#818cf8" linewidth={2} depthTest={false} depthWrite={false} opacity={0.3} transparent />
</line>
{/* First corner marker */}
{corner1 && (
<CursorSphere
position={[corner1[0], levelY + GRID_OFFSET, corner1[2]]}
color="#818cf8"
showTooltip={false}
/>
)}
{/* Thin preview fill when drawing (Ground) */}
{previewDimensions && previewDimensions.length > 0.1 && previewDimensions.width > 0.1 && (
<mesh
layers={EDITOR_LAYER}
position={[previewDimensions.centerX, levelY + GRID_OFFSET, previewDimensions.centerZ]}
rotation={[-Math.PI / 2, 0, 0]}
>
<planeGeometry args={[previewDimensions.length, previewDimensions.width]} />
<meshBasicMaterial
color="#818cf8"
opacity={0.1}
transparent
side={DoubleSide}
depthTest={false}
depthWrite={false}
/>
</mesh>
)}
</group>
)
}
@@ -1,94 +0,0 @@
import type { ThreeElements } from '@react-three/fiber'
import { forwardRef } from 'react'
import type { Group } from 'three'
import { Html } from '@react-three/drei'
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
depthWrite?: boolean
showTooltip?: boolean
height?: number
}
export const CursorSphere = forwardRef<Group, CursorSphereProps>(function CursorSphere(
{ color = '#818cf8', showTooltip = true, height = 2.5, ...props },
ref,
) {
const tool = useEditor((s) => s.tool)
const mode = useEditor((s) => s.mode)
const catalogCategory = useEditor((s) => s.catalogCategory)
// Find the icon for the current tool
let activeToolConfig = null
if (mode === 'build' && tool) {
if (tool === 'item' && catalogCategory) {
activeToolConfig = furnishTools.find((t) => t.catalogCategory === catalogCategory)
} else {
activeToolConfig = tools.find((t) => t.id === tool)
}
}
return (
<group ref={ref} {...props}>
{/* Flat marker on the ground */}
<group rotation={[-Math.PI / 2, 0, 0]}>
{/* Center dot */}
<mesh renderOrder={2} layers={EDITOR_LAYER}>
<circleGeometry args={[0.06, 32]} />
<meshBasicMaterial color={color} depthTest={false} depthWrite={false} transparent opacity={0.9} />
</mesh>
{/* Outer ring / glow */}
<mesh renderOrder={2} layers={EDITOR_LAYER}>
<circleGeometry args={[0.2, 32]} />
<meshBasicMaterial color={color} depthTest={false} depthWrite={false} transparent opacity={0.25} />
</mesh>
</group>
{/* Vertical line */}
{height > 0 && (
<mesh position={[0, height / 2, 0]} renderOrder={2} layers={EDITOR_LAYER}>
<cylinderGeometry args={[0.01, 0.01, height, 8]} />
<meshBasicMaterial color={color} depthTest={false} depthWrite={false} transparent opacity={0.7} />
</mesh>
)}
{/* Tool Icon Tooltip at the top of the line */}
{showTooltip && activeToolConfig && (
<Html
position={[0, height > 0 ? height + 0.2 : 0.6, 0]}
center
style={{
pointerEvents: 'none',
background: '#18181b', // zinc-900
padding: '6px',
borderRadius: '12px',
border: '1px solid rgba(255,255,255,0.05)',
boxShadow: '0 8px 16px -4px rgba(0, 0, 0, 0.3), 0 4px 8px -4px rgba(0, 0, 0, 0.2)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '36px',
height: '36px',
}}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={activeToolConfig.iconSrc}
alt={activeToolConfig.label}
style={{
width: '100%',
height: '100%',
objectFit: 'contain',
filter: 'drop-shadow(0px 2px 4px rgba(0,0,0,0.5))'
}}
/>
</Html>
)}
</group>
)
})
@@ -1,361 +0,0 @@
import { emitter, type GridEvent, sceneRegistry } from '@pascal-app/core'
import { createPortal } from '@react-three/fiber'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { BufferGeometry, Float32BufferAttribute, type Line } from 'three'
import { EDITOR_LAYER } from '@/lib/constants'
import { sfxEmitter } from '@/lib/sfx-bus'
const Y_OFFSET = 0.02
type DragState = {
isDragging: boolean
vertexIndex: number
initialPosition: [number, number]
pointerId: number
}
export interface PolygonEditorProps {
polygon: Array<[number, number]>
color?: string
onPolygonChange: (polygon: Array<[number, number]>) => void
minVertices?: number
/** Level ID to mount the editor to. If provided, uses createPortal for automatic level animation following. */
levelId?: string
/** Height of the surface being edited (e.g. slab elevation). Handles adapt to this. */
surfaceHeight?: number
}
/**
* Generic polygon editor component for editing polygon vertices
* Used by zone and site boundary editors
*/
const MIN_HANDLE_HEIGHT = 0.15
export const PolygonEditor: React.FC<PolygonEditorProps> = ({
polygon,
color = '#3b82f6',
onPolygonChange,
minVertices = 3,
levelId,
surfaceHeight = 0,
}) => {
// Get level node from registry if levelId is provided
const levelNode = levelId ? sceneRegistry.nodes.get(levelId) : null
// When using portal, edit at Y_OFFSET (local to level)
// When not using portal, edit at world origin
const editY = levelNode ? Y_OFFSET : 0
// Local state for dragging
const [dragState, setDragState] = useState<DragState | null>(null)
const [previewPolygon, setPreviewPolygon] = useState<Array<[number, number]> | null>(null)
const previewPolygonRef = useRef<Array<[number, number]> | null>(null)
// Keep ref in sync
useEffect(() => {
previewPolygonRef.current = previewPolygon
}, [previewPolygon])
const [hoveredVertex, setHoveredVertex] = useState<number | null>(null)
const [hoveredMidpoint, setHoveredMidpoint] = useState<number | null>(null)
const [cursorPosition, setCursorPosition] = useState<[number, number]>([0, 0])
const lineRef = useRef<Line>(null!)
const previousPositionRef = useRef<[number, number] | null>(null)
// Track the last polygon prop to detect external changes (undo/redo)
const lastPolygonRef = useRef(polygon)
if (polygon !== lastPolygonRef.current) {
lastPolygonRef.current = polygon
// External change (e.g. undo/redo) — clear any stale preview/drag state
if (previewPolygon) setPreviewPolygon(null)
if (dragState) setDragState(null)
}
// The polygon to display (preview during drag, or actual polygon)
const displayPolygon = previewPolygon ?? polygon
// Calculate midpoints for adding new vertices
const midpoints = useMemo(() => {
if (displayPolygon.length < 2) return []
return displayPolygon.map(([x1, z1], index) => {
const nextIndex = (index + 1) % displayPolygon.length
const [x2, z2] = displayPolygon[nextIndex]!
return [(x1! + x2) / 2, (z1! + z2) / 2] as [number, number]
})
}, [displayPolygon])
// Update vertex position using grid cursor position
const handleVertexDrag = useCallback(
(vertexIndex: number, position: [number, number]) => {
setPreviewPolygon((prev) => {
const basePolygon = prev ?? polygon
const newPolygon = [...basePolygon]
newPolygon[vertexIndex] = position
return newPolygon
})
},
[polygon],
)
// Commit polygon changes
const commitPolygonChange = useCallback(() => {
if (previewPolygonRef.current) {
onPolygonChange(previewPolygonRef.current)
}
setPreviewPolygon(null)
setDragState(null)
}, [onPolygonChange])
// Handle adding a new vertex at midpoint
const handleAddVertex = useCallback(
(afterIndex: number, position: [number, number]) => {
const basePolygon = previewPolygon ?? polygon
const newPolygon = [
...basePolygon.slice(0, afterIndex + 1),
position,
...basePolygon.slice(afterIndex + 1),
]
setPreviewPolygon(newPolygon)
return afterIndex + 1 // Return new vertex index
},
[polygon, previewPolygon],
)
// Handle deleting a vertex
const handleDeleteVertex = useCallback(
(index: number) => {
const basePolygon = previewPolygon ?? polygon
if (basePolygon.length <= minVertices) return // Need at least minVertices points
const newPolygon = basePolygon.filter((_, i) => i !== index)
onPolygonChange(newPolygon)
setPreviewPolygon(null)
},
[polygon, previewPolygon, onPolygonChange, minVertices],
)
// Listen to grid:move events to track cursor position
useEffect(() => {
const onGridMove = (event: GridEvent) => {
const gridX = Math.round(event.position[0] * 2) / 2
const gridZ = Math.round(event.position[2] * 2) / 2
const newPosition: [number, number] = [gridX, gridZ]
// Play snap sound when cursor moves to a new grid cell during drag
if (
dragState?.isDragging &&
previousPositionRef.current &&
(newPosition[0] !== previousPositionRef.current[0] ||
newPosition[1] !== previousPositionRef.current[1])
) {
sfxEmitter.emit('sfx:grid-snap')
}
previousPositionRef.current = newPosition
setCursorPosition(newPosition)
// Update vertex position during drag
if (dragState?.isDragging) {
handleVertexDrag(dragState.vertexIndex, newPosition)
}
}
emitter.on('grid:move', onGridMove)
return () => {
emitter.off('grid:move', onGridMove)
}
}, [dragState, handleVertexDrag])
// Set up pointer up listener for ending drag
useEffect(() => {
if (!dragState?.isDragging) return
const handlePointerUp = (e: PointerEvent | MouseEvent) => {
// Only handle the specific pointer that started the drag, if it's a PointerEvent
if (
'pointerId' in e &&
dragState.pointerId !== undefined &&
e.pointerId !== dragState.pointerId
)
return
// Stop the event from propagating to prevent grid click
e.stopImmediatePropagation()
e.preventDefault()
// Suppress the follow-up click event that browsers fire after pointerup
const suppressClick = (ce: MouseEvent) => {
ce.stopImmediatePropagation()
ce.preventDefault()
window.removeEventListener('click', suppressClick, true)
}
window.addEventListener('click', suppressClick, true)
// Safety cleanup in case no click fires
requestAnimationFrame(() => {
window.removeEventListener('click', suppressClick, true)
})
commitPolygonChange()
}
window.addEventListener('pointerup', handlePointerUp as EventListener, true)
window.addEventListener('pointercancel', handlePointerUp as EventListener, true)
return () => {
window.removeEventListener('pointerup', handlePointerUp as EventListener, true)
window.removeEventListener('pointercancel', handlePointerUp as EventListener, true)
}
}, [dragState, commitPolygonChange])
// Update line geometry when polygon changes
useEffect(() => {
if (!lineRef.current || displayPolygon.length < 2) return
const positions: number[] = []
for (const [x, z] of displayPolygon) {
positions.push(x!, editY + 0.01, z!)
}
// Close the loop
const first = displayPolygon[0]!
positions.push(first[0]!, editY + 0.01, first[1]!)
const geometry = new BufferGeometry()
geometry.setAttribute('position', new Float32BufferAttribute(positions, 3))
lineRef.current.geometry.dispose()
lineRef.current.geometry = geometry
}, [displayPolygon, editY])
if (displayPolygon.length < minVertices) return null
const canDelete = displayPolygon.length > minVertices
const editorContent = (
<group>
{/* Border line */}
<line
// @ts-expect-error R3F <line> element conflicts with SVG <line> type
ref={lineRef}
frustumCulled={false}
renderOrder={10}
raycast={() => {}}
layers={EDITOR_LAYER}
>
<bufferGeometry />
<lineBasicNodeMaterial
color={color}
linewidth={2}
depthTest={false}
depthWrite={false}
transparent
opacity={0.8}
/>
</line>
{/* Vertex handles - blue cylinders that match surface height */}
{displayPolygon.map(([x, z], index) => {
const isHovered = hoveredVertex === index
const isDragging = dragState?.vertexIndex === index
const radius = 0.1
const height = Math.max(MIN_HANDLE_HEIGHT, surfaceHeight + 0.02)
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,
})
}}
onClick={(e) => {
if (e.button !== 0) return
e.stopPropagation()
}}
onDoubleClick={(e) => {
if (e.button !== 0) return
e.stopPropagation()
if (canDelete) {
handleDeleteVertex(index)
}
}}
>
<cylinderGeometry args={[radius, radius, height, 16]} />
<meshStandardMaterial
color={isDragging ? '#22c55e' : isHovered ? '#60a5fa' : '#3b82f6'}
/>
</mesh>
)
})}
{/* Midpoint handles - smaller green cylinders for adding vertices (hidden while dragging) */}
{!dragState &&
midpoints.map(([x, z], index) => {
const isHovered = hoveredMidpoint === index
const radius = 0.06
const height = Math.max(MIN_HANDLE_HEIGHT, surfaceHeight + 0.02)
return (
<mesh
layers={EDITOR_LAYER}
key={`midpoint-${index}`}
position={[x!, editY + height / 2, z!]}
onPointerEnter={(e) => {
e.stopPropagation()
setHoveredMidpoint(index)
}}
onPointerLeave={(e) => {
e.stopPropagation()
setHoveredMidpoint(null)
}}
onPointerDown={(e) => {
if (e.button !== 0) return
e.stopPropagation()
const newVertexIndex = handleAddVertex(index, [x!, z!])
if (newVertexIndex >= 0) {
setDragState({
isDragging: true,
vertexIndex: newVertexIndex,
initialPosition: [x!, z!],
pointerId: e.pointerId,
})
setHoveredMidpoint(null)
}
}}
onClick={(e) => {
if (e.button !== 0) return
e.stopPropagation()
}}
>
<cylinderGeometry args={[radius, radius, height, 16]} />
<meshStandardMaterial
color={isHovered ? '#4ade80' : '#22c55e'}
transparent
opacity={isHovered ? 1 : 0.7}
/>
</mesh>
)
})}
</group>
)
// Mount to level node if available, otherwise render at world origin
return levelNode ? createPortal(editorContent, levelNode) : editorContent
}
@@ -1,42 +0,0 @@
import { type SiteNode, useScene } from '@pascal-app/core'
import { useCallback } from 'react'
import { PolygonEditor } from '../shared/polygon-editor'
/**
* Site boundary editor - allows editing site polygon when in site phase
* Uses the generic PolygonEditor component
*/
export const SiteBoundaryEditor: React.FC = () => {
const nodes = useScene((state) => state.nodes)
const rootNodeIds = useScene((state) => state.rootNodeIds)
const updateNode = useScene((state) => state.updateNode)
// Get the site node (first root node)
const siteNode = rootNodeIds[0] ? nodes[rootNodeIds[0]] : null
const site = siteNode?.type === 'site' ? (siteNode as SiteNode) : null
const handlePolygonChange = useCallback(
(newPolygon: Array<[number, number]>) => {
if (site) {
updateNode(site.id, {
polygon: {
type: 'polygon',
points: newPolygon,
},
})
}
},
[site, updateNode],
)
if (!site || !site.polygon?.points || site.polygon.points.length < 3) return null
return (
<PolygonEditor
polygon={site.polygon.points}
color="#10b981"
onPolygonChange={handlePolygonChange}
minVertices={3}
/>
)
}
@@ -1,42 +0,0 @@
import { resolveLevelId, type SlabNode, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useCallback } from 'react'
import { PolygonEditor } from '../shared/polygon-editor'
interface SlabBoundaryEditorProps {
slabId: SlabNode['id']
}
/**
* Slab boundary editor - allows editing slab polygon vertices for a specific slab
* Uses the generic PolygonEditor component
*/
export const SlabBoundaryEditor: React.FC<SlabBoundaryEditorProps> = ({ slabId }) => {
const slabNode = useScene((state) => state.nodes[slabId])
const updateNode = useScene((state) => state.updateNode)
const setSelection = useViewer((state) => state.setSelection)
const slab = slabNode?.type === 'slab' ? (slabNode as SlabNode) : null
const handlePolygonChange = useCallback(
(newPolygon: Array<[number, number]>) => {
updateNode(slabId, { polygon: newPolygon })
// Re-assert selection so the slab stays selected after the edit
setSelection({ selectedIds: [slabId] })
},
[slabId, updateNode, setSelection],
)
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)}
surfaceHeight={slab.elevation ?? 0.05}
/>
)
}
@@ -1,47 +0,0 @@
import { resolveLevelId, type SlabNode, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useCallback } from 'react'
import { PolygonEditor } from '../shared/polygon-editor'
interface SlabHoleEditorProps {
slabId: SlabNode['id']
holeIndex: number
}
/**
* Slab hole editor - allows editing a specific hole polygon within a slab
* Uses the generic PolygonEditor component
*/
export const SlabHoleEditor: React.FC<SlabHoleEditorProps> = ({ slabId, holeIndex }) => {
const slabNode = useScene((state) => state.nodes[slabId])
const updateNode = useScene((state) => state.updateNode)
const setSelection = useViewer((state) => state.setSelection)
const slab = slabNode?.type === 'slab' ? (slabNode as SlabNode) : null
const holes = slab?.holes || []
const hole = holes[holeIndex]
const handlePolygonChange = useCallback(
(newPolygon: Array<[number, number]>) => {
const updatedHoles = [...holes]
updatedHoles[holeIndex] = newPolygon
updateNode(slabId, { holes: updatedHoles })
// Re-assert selection so the slab stays selected after the edit
setSelection({ selectedIds: [slabId] })
},
[slabId, holeIndex, holes, updateNode, setSelection],
)
if (!slab || !hole || hole.length < 3) return null
return (
<PolygonEditor
polygon={hole}
color="#ef4444" // red for holes
onPolygonChange={handlePolygonChange}
minVertices={3}
levelId={resolveLevelId(slab, useScene.getState().nodes)}
surfaceHeight={slab.elevation ?? 0.05}
/>
)
}
@@ -1,289 +0,0 @@
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 { EDITOR_LAYER } from '@/lib/constants'
import { sfxEmitter } from '@/lib/sfx-bus'
import { CursorSphere } from '../shared/cursor-sphere'
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],
): [number, number] => {
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)
// Calculate distances to horizontal, vertical, and diagonal lines
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)
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]
}
}
/**
* Creates a slab with the given polygon points and returns its ID
*/
const commitSlabDrawing = (levelId: LevelNode['id'], points: Array<[number, number]>): string => {
const { createNode, nodes } = useScene.getState()
// Count existing slabs for naming
const slabCount = Object.values(nodes).filter((n) => n.type === 'slab').length
const name = `Slab ${slabCount + 1}`
const slab = SlabNode.parse({
name,
polygon: points,
})
createNode(slab, levelId)
sfxEmitter.emit('sfx:structure-build')
return slab.id
}
export const SlabTool: React.FC = () => {
const cursorRef = useRef<Group>(null)
const mainLineRef = useRef<Line>(null!)
const closingLineRef = useRef<Line>(null!)
const currentLevelId = useViewer((state) => state.selection.levelId)
const setSelection = useViewer((state) => state.setSelection)
const [points, setPoints] = useState<Array<[number, number]>>([])
const [cursorPosition, setCursorPosition] = useState<[number, number]>([0, 0])
const [snappedCursorPosition, setSnappedCursorPosition] = useState<[number, number]>([0, 0])
const [levelY, setLevelY] = useState(0)
const previousSnappedPointRef = useRef<[number, number] | null>(null)
const shiftPressed = useRef(false)
// Update cursor position and lines on grid move
useEffect(() => {
if (!currentLevelId) return
const onGridMove = (event: GridEvent) => {
if (!cursorRef.current) return
const gridX = Math.round(event.position[0] * 2) / 2
const gridZ = Math.round(event.position[2] * 2) / 2
const gridPosition: [number, number] = [gridX, gridZ]
setCursorPosition(gridPosition)
setLevelY(event.position[1])
// 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)
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])) {
sfxEmitter.emit('sfx:grid-snap')
}
previousSnappedPointRef.current = displayPoint
cursorRef.current.position.set(displayPoint[0], event.position[1], displayPoint[1])
}
const onGridClick = (_event: GridEvent) => {
if (!currentLevelId) return
// Use the last displayed snapped position (respects Shift state from onGridMove)
const clickPoint = previousSnappedPointRef.current ?? cursorPosition
// Check if clicking on the first point to close the shape
const firstPoint = points[0]
if (
points.length >= 3 &&
firstPoint &&
Math.abs(clickPoint[0] - firstPoint[0]) < 0.25 &&
Math.abs(clickPoint[1] - firstPoint[1]) < 0.25
) {
// Create the slab and select it
const slabId = commitSlabDrawing(currentLevelId, points)
setSelection({ selectedIds: [slabId] })
setPoints([])
} else {
// Add point to polygon
setPoints([...points, clickPoint])
}
}
const onGridDoubleClick = (_event: GridEvent) => {
if (!currentLevelId) return
// Need at least 3 points to form a polygon
if (points.length >= 3) {
const slabId = commitSlabDrawing(currentLevelId, points)
setSelection({ selectedIds: [slabId] })
setPoints([])
}
}
const onCancel = () => {
setPoints([])
}
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)
emitter.on('grid:move', onGridMove)
emitter.on('grid:click', onGridClick)
emitter.on('grid:double-click', onGridDoubleClick)
emitter.on('tool:cancel', onCancel)
return () => {
document.removeEventListener('keydown', onKeyDown)
document.removeEventListener('keyup', onKeyUp)
emitter.off('grid:move', onGridMove)
emitter.off('grid:click', onGridClick)
emitter.off('grid:double-click', onGridDoubleClick)
emitter.off('tool:cancel', onCancel)
}
}, [currentLevelId, points, cursorPosition, setSelection])
// Update line geometries when points change
useEffect(() => {
if (!mainLineRef.current || !closingLineRef.current) return
if (points.length === 0) {
mainLineRef.current.visible = false
closingLineRef.current.visible = false
return
}
const y = levelY + Y_OFFSET
const snappedCursor = snappedCursorPosition
// Build main line points
const linePoints: Vector3[] = points.map(([x, z]) => new Vector3(x, y, z))
linePoints.push(new Vector3(snappedCursor[0], y, snappedCursor[1]))
// Update main line
if (linePoints.length >= 2) {
mainLineRef.current.geometry.dispose()
mainLineRef.current.geometry = new BufferGeometry().setFromPoints(linePoints)
mainLineRef.current.visible = true
} else {
mainLineRef.current.visible = false
}
// Update closing line (from cursor back to first point)
const firstPoint = points[0]
if (points.length >= 2 && firstPoint) {
const closingPoints = [
new Vector3(snappedCursor[0], y, snappedCursor[1]),
new Vector3(firstPoint[0], y, firstPoint[1]),
]
closingLineRef.current.geometry.dispose()
closingLineRef.current.geometry = new BufferGeometry().setFromPoints(closingPoints)
closingLineRef.current.visible = true
} else {
closingLineRef.current.visible = false
}
}, [points, snappedCursorPosition, levelY])
// Create preview shape when we have 3+ points
const previewShape = useMemo(() => {
if (points.length < 3) return null
const snappedCursor = snappedCursorPosition
const allPoints = [...points, snappedCursor]
// 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 (!firstPt) return null
const shape = new Shape()
shape.moveTo(firstPt[0], -firstPt[1])
for (let i = 1; i < allPoints.length; i++) {
const pt = allPoints[i]
if (pt) {
shape.lineTo(pt[0], -pt[1])
}
}
shape.closePath()
return shape
}, [points, snappedCursorPosition])
return (
<group>
{/* Cursor */}
<CursorSphere ref={cursorRef} />
{/* Preview fill */}
{previewShape && (
<mesh
frustumCulled={false}
layers={EDITOR_LAYER}
position={[0, levelY + Y_OFFSET, 0]}
rotation={[-Math.PI / 2, 0, 0]}
>
<shapeGeometry args={[previewShape]} />
<meshBasicMaterial
color="#818cf8"
depthTest={false}
opacity={0.15}
side={DoubleSide}
transparent
/>
</mesh>
)}
{/* Main line */}
{/* @ts-ignore */}
<line ref={mainLineRef} frustumCulled={false} renderOrder={1} visible={false} layers={EDITOR_LAYER}>
<bufferGeometry />
<lineBasicNodeMaterial color="#818cf8" linewidth={3} depthTest={false} depthWrite={false} />
</line>
{/* Closing line */}
{/* @ts-ignore */}
<line ref={closingLineRef} frustumCulled={false} renderOrder={1} visible={false} layers={EDITOR_LAYER}>
<bufferGeometry />
<lineBasicNodeMaterial
color="#818cf8"
linewidth={2}
depthTest={false}
depthWrite={false}
opacity={0.5}
transparent
/>
</line>
{/* Point markers */}
{points.map(([x, z], index) => (
<CursorSphere key={index} position={[x, levelY + Y_OFFSET + 0.01, z]} color="#818cf8" showTooltip={false} height={0} />
))}
</group>
)
}
@@ -1,112 +0,0 @@
import { type AnyNodeId, type CeilingNode, type SlabNode, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import useEditor, { type Phase, type Tool } from '@/store/use-editor'
import { CeilingBoundaryEditor } from './ceiling/ceiling-boundary-editor'
import { CeilingHoleEditor } from './ceiling/ceiling-hole-editor'
import { CeilingTool } from './ceiling/ceiling-tool'
import { DoorTool } from './door/door-tool'
import { ItemTool } from './item/item-tool'
import { MoveTool } from './item/move-tool'
import { RoofTool } from './roof/roof-tool'
import { SiteBoundaryEditor } from './site/site-boundary-editor'
import { SlabBoundaryEditor } from './slab/slab-boundary-editor'
import { SlabHoleEditor } from './slab/slab-hole-editor'
import { SlabTool } from './slab/slab-tool'
import { WallTool } from './wall/wall-tool'
import { WindowTool } from './window/window-tool'
import { ZoneBoundaryEditor } from './zone/zone-boundary-editor'
import { ZoneTool } from './zone/zone-tool'
const tools: Record<Phase, Partial<Record<Tool, React.FC>>> = {
site: {
'property-line': SiteBoundaryEditor,
},
structure: {
wall: WallTool,
slab: SlabTool,
ceiling: CeilingTool,
roof: RoofTool,
door: DoorTool,
item: ItemTool,
zone: ZoneTool,
window: WindowTool,
},
furnish: {
item: ItemTool,
},
}
export const ToolManager: React.FC = () => {
const phase = useEditor((state) => state.phase)
const mode = useEditor((state) => state.mode)
const tool = useEditor((state) => state.tool)
const movingNode = useEditor((state) => state.movingNode)
const editingHole = useEditor((state) => state.editingHole)
const selectedZoneId = useViewer((state) => state.selection.zoneId)
const selectedIds = useViewer((state) => state.selection.selectedIds)
const nodes = useScene((state) => state.nodes)
// Check if a slab is selected
const selectedSlabId = selectedIds.find((id) => nodes[id as AnyNodeId]?.type === 'slab') as
| SlabNode['id']
| undefined
// Check if a ceiling is selected
const selectedCeilingId = selectedIds.find((id) => nodes[id as AnyNodeId]?.type === 'ceiling') as
| CeilingNode['id']
| undefined
// Show site boundary editor when in site phase and edit mode
const showSiteBoundaryEditor = phase === 'site' && mode === 'edit'
// 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 &&
(!editingHole || editingHole.nodeId !== selectedSlabId)
// Show slab hole editor when editing a hole on the selected slab
const showSlabHoleEditor =
selectedSlabId !== undefined && editingHole !== null && editingHole.nodeId === selectedSlabId
// 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 &&
(!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
// Show zone boundary editor when in structure/select mode with a zone selected
// Hide when editing a slab or ceiling to avoid overlapping handles
const showZoneBoundaryEditor =
phase === 'structure' &&
mode === 'select' &&
selectedZoneId !== null &&
!showSlabBoundaryEditor &&
!showCeilingBoundaryEditor
// Show build tools when in build mode
const showBuildTool = mode === 'build' && tool !== null
const BuildToolComponent = showBuildTool ? tools[phase]?.[tool] : null
return (
<>
{showSiteBoundaryEditor && <SiteBoundaryEditor />}
{showZoneBoundaryEditor && selectedZoneId && <ZoneBoundaryEditor zoneId={selectedZoneId} />}
{showSlabBoundaryEditor && selectedSlabId && <SlabBoundaryEditor slabId={selectedSlabId} />}
{showSlabHoleEditor && selectedSlabId && editingHole && (
<SlabHoleEditor slabId={selectedSlabId} holeIndex={editingHole.holeIndex} />
)}
{showCeilingBoundaryEditor && selectedCeilingId && (
<CeilingBoundaryEditor ceilingId={selectedCeilingId} />
)}
{showCeilingHoleEditor && selectedCeilingId && editingHole && (
<CeilingHoleEditor ceilingId={selectedCeilingId} holeIndex={editingHole.holeIndex} />
)}
{movingNode && <MoveTool />}
{!movingNode && BuildToolComponent && <BuildToolComponent />}
</>
)
}
@@ -1,215 +0,0 @@
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 { EDITOR_LAYER } from '@/lib/constants'
import { sfxEmitter } from '@/lib/sfx-bus'
import { CursorSphere } from '../shared/cursor-sphere'
const WALL_HEIGHT = 2.5
const WALL_THICKNESS = 0.15
/**
* Snap point to 45° angle increments relative to start point
* Also snaps end point to 0.5 grid
*/
const snapTo45Degrees = (start: Vector3, cursor: Vector3): Vector3 => {
const dx = cursor.x - start.x
const dz = cursor.z - start.z
// Calculate angle in radians
const angle = Math.atan2(dz, dx)
// Round to nearest 45° (π/4 radians)
const snappedAngle = Math.round(angle / (Math.PI / 4)) * (Math.PI / 4)
// Calculate distance from start to cursor
const distance = Math.sqrt(dx * dx + dz * dz)
// Project end point along snapped angle
let snappedX = start.x + Math.cos(snappedAngle) * distance
let snappedZ = start.z + Math.sin(snappedAngle) * distance
// Snap to 0.5 grid
snappedX = Math.round(snappedX * 2) / 2
snappedZ = Math.round(snappedZ * 2) / 2
return new Vector3(snappedX, cursor.y, snappedZ)
}
/**
* Update wall preview mesh geometry to create a vertical plane between two points
*/
const updateWallPreview = (mesh: Mesh, start: Vector3, end: Vector3) => {
// Calculate direction and perpendicular for wall thickness
const direction = new Vector3(end.x - start.x, 0, end.z - start.z)
const length = direction.length()
if (length < 0.01) {
mesh.visible = false
return
}
mesh.visible = true
direction.normalize()
// Perpendicular vector for thickness
const perpendicular = new Vector3(-direction.z, 0, direction.x).multiplyScalar(WALL_THICKNESS / 2)
// Create wall shape (vertical rectangle in XY plane)
const shape = new Shape()
shape.moveTo(0, 0)
shape.lineTo(length, 0)
shape.lineTo(length, WALL_HEIGHT)
shape.lineTo(0, WALL_HEIGHT)
shape.closePath()
// Create geometry
const geometry = new ShapeGeometry(shape)
// Calculate rotation angle
// Negate the angle to fix the opposite direction issue
const angle = -Math.atan2(direction.z, direction.x)
// Position at start point and rotate
mesh.position.set(start.x, start.y, start.z)
mesh.rotation.y = angle
// Dispose old geometry and assign new one
if (mesh.geometry) {
mesh.geometry.dispose()
}
mesh.geometry = geometry
}
const commitWallDrawing = (start: [number, number], end: [number, number]) => {
const currentLevelId = useViewer.getState().selection.levelId
const { createNode, nodes } = useScene.getState()
if (!currentLevelId) return
const wallCount = Object.values(nodes).filter((n) => n.type === 'wall').length
const name = `Wall ${wallCount + 1}`
const wall = WallNode.parse({ name, start, end })
createNode(wall, currentLevelId)
sfxEmitter.emit('sfx:structure-build')
}
export const WallTool: React.FC = () => {
const cursorRef = useRef<Group>(null)
const wallPreviewRef = useRef<Mesh>(null!)
const startingPoint = useRef(new Vector3(0, 0, 0))
const endingPoint = useRef(new Vector3(0, 0, 0))
const buildingState = useRef(0)
const shiftPressed = useRef(false)
useEffect(() => {
let gridPosition: [number, number] = [0, 0]
let previousWallEnd: [number, number] | null = null
const onGridMove = (event: GridEvent) => {
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])
if (buildingState.current === 1) {
// Snap to 45° angles only if shift is not pressed
const snapped = shiftPressed.current
? cursorPosition
: snapTo45Degrees(startingPoint.current, cursorPosition)
endingPoint.current.copy(snapped)
// Position the cursor at the end of the wall being drawn
cursorRef.current.position.set(snapped.x, snapped.y, snapped.z)
// 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])) {
sfxEmitter.emit('sfx:grid-snap')
}
previousWallEnd = currentWallEnd
// Update wall preview geometry
updateWallPreview(wallPreviewRef.current, startingPoint.current, endingPoint.current)
} else {
// Not drawing a wall, just follow the grid position
cursorRef.current.position.set(gridPosition[0], event.position[1], gridPosition[1])
}
}
const onGridClick = (event: GridEvent) => {
if (buildingState.current === 0) {
startingPoint.current.set(gridPosition[0], event.position[1], gridPosition[1])
buildingState.current = 1
wallPreviewRef.current.visible = true
} else if (buildingState.current === 1) {
const dx = endingPoint.current.x - startingPoint.current.x
const dz = endingPoint.current.z - startingPoint.current.z
if (dx * dx + dz * dz < 0.01 * 0.01) return
commitWallDrawing(
[startingPoint.current.x, startingPoint.current.z],
[endingPoint.current.x, endingPoint.current.z],
)
wallPreviewRef.current.visible = false
buildingState.current = 0
}
}
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Shift') {
shiftPressed.current = true
}
}
const onKeyUp = (e: KeyboardEvent) => {
if (e.key === 'Shift') {
shiftPressed.current = false
}
}
const onCancel = () => {
if (buildingState.current === 1) {
buildingState.current = 0
wallPreviewRef.current.visible = false
}
}
emitter.on('grid:move', onGridMove)
emitter.on('grid:click', onGridClick)
emitter.on('tool:cancel', onCancel)
window.addEventListener('keydown', onKeyDown)
window.addEventListener('keyup', onKeyUp)
return () => {
emitter.off('grid:move', onGridMove)
emitter.off('grid:click', onGridClick)
emitter.off('tool:cancel', onCancel)
window.removeEventListener('keydown', onKeyDown)
window.removeEventListener('keyup', onKeyUp)
}
}, [])
return (
<group>
{/* Cursor indicator */}
<CursorSphere ref={cursorRef} />
{/* Wall preview */}
<mesh ref={wallPreviewRef} visible={false} renderOrder={1} layers={EDITOR_LAYER}>
<shapeGeometry />
<meshBasicMaterial
color="#818cf8"
transparent
opacity={0.5}
side={DoubleSide}
depthTest={false}
depthWrite={false}
/>
</mesh>
</group>
)
}
@@ -1,377 +0,0 @@
import {
type AnyNodeId,
emitter,
sceneRegistry,
spatialGridManager,
useScene,
type WallEvent,
WindowNode,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useEffect, useMemo, useRef } from 'react'
import { BoxGeometry, EdgesGeometry, type Group } from 'three'
import { LineBasicNodeMaterial } from 'three/webgpu'
import { EDITOR_LAYER } from '@/lib/constants'
import { sfxEmitter } from '@/lib/sfx-bus'
import useEditor from '@/store/use-editor'
import {
calculateCursorRotation,
calculateItemRotation,
getSideFromNormal,
isValidWallSideFace,
snapToHalf,
} from '../item/placement-math'
import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './window-math'
const edgeMaterial = new LineBasicNodeMaterial({
color: 0xef4444,
linewidth: 3,
depthTest: false,
depthWrite: false,
})
/**
* Move/duplicate tool for WindowNodes — wall-only, same guardrails as WindowTool.
*
* Move mode (metadata.isNew falsy):
* Adopts the existing window, pauses temporal. On commit: restores original state
* (clean undo baseline) then resumes + updateNode (undo reverts to original position).
* On cancel: restores original state.
*
* Duplicate mode (metadata.isNew = true):
* The node is a freshly created transient copy. On commit: deletes transient + resumes
* + createNode (undo removes the new window entirely). On cancel: deletes the node.
*/
export const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode }) => {
const cursorGroupRef = useRef<Group>(null!)
const exitMoveMode = () => {
useEditor.getState().setMovingNode(null)
}
useEffect(() => {
useScene.temporal.getState().pause()
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)
const original = {
position: [...movingWindowNode.position] as [number, number, number],
rotation: [...movingWindowNode.rotation] as [number, number, number],
side: movingWindowNode.side,
parentId: movingWindowNode.parentId,
wallId: movingWindowNode.wallId,
metadata: movingWindowNode.metadata,
}
if (!isNew) {
// Move mode: mark the existing window as transient so it hides while being repositioned
useScene.getState().updateNode(movingWindowNode.id, {
metadata: { ...meta, isTransient: true },
})
}
let currentWallId: string | null = movingWindowNode.parentId
const markWallDirty = (wallId: string | null) => {
if (wallId) useScene.getState().dirtyNodes.add(wallId as AnyNodeId)
}
const getLevelId = () => useViewer.getState().selection.levelId
const getLevelYOffset = () => {
const id = getLevelId()
return id ? (sceneRegistry.nodes.get(id as AnyNodeId)?.position.y ?? 0) : 0
}
const getSlabElevation = (wallEvent: WallEvent) =>
spatialGridManager.getSlabElevationForWall(
wallEvent.node.parentId ?? '',
wallEvent.node.start,
wallEvent.node.end,
)
const hideCursor = () => {
if (cursorGroupRef.current) cursorGroupRef.current.visible = false
}
const updateCursor = (
worldPosition: [number, number, number],
cursorRotationY: number,
valid: boolean,
) => {
const group = cursorGroupRef.current
if (!group) return
group.visible = true
group.position.set(...worldPosition)
group.rotation.y = cursorRotationY
edgeMaterial.color.setHex(valid ? 0x22c55e : 0xef4444)
}
const onWallEnter = (event: WallEvent) => {
if (!isValidWallSideFace(event.normal)) return
// Only interact with walls on the current level
if (event.node.parentId !== getLevelId()) return
const side = getSideFromNormal(event.normal)
const itemRotation = calculateItemRotation(event.normal)
const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)
const localX = snapToHalf(event.localPosition[0])
const localY = snapToHalf(event.localPosition[1])
const { clampedX, clampedY } = clampToWall(
event.node, localX, localY,
movingWindowNode.width, movingWindowNode.height,
)
const prevWallId = currentWallId
currentWallId = event.node.id
useScene.getState().updateNode(movingWindowNode.id, {
position: [clampedX, clampedY, 0],
rotation: [0, itemRotation, 0],
side,
parentId: event.node.id,
wallId: event.node.id,
})
if (prevWallId && prevWallId !== event.node.id) markWallDirty(prevWallId)
markWallDirty(event.node.id)
const valid = !hasWallChildOverlap(
event.node.id, clampedX, clampedY,
movingWindowNode.width, movingWindowNode.height,
movingWindowNode.id,
)
updateCursor(
wallLocalToWorld(event.node, clampedX, clampedY, getLevelYOffset(), getSlabElevation(event)),
cursorRotation,
valid,
)
event.stopPropagation()
}
const onWallMove = (event: WallEvent) => {
if (!isValidWallSideFace(event.normal)) return
// Only interact with walls on the current level
if (event.node.parentId !== getLevelId()) return
const side = getSideFromNormal(event.normal)
const itemRotation = calculateItemRotation(event.normal)
const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)
const localX = snapToHalf(event.localPosition[0])
const localY = snapToHalf(event.localPosition[1])
const { clampedX, clampedY } = clampToWall(
event.node, localX, localY,
movingWindowNode.width, movingWindowNode.height,
)
useScene.getState().updateNode(movingWindowNode.id, {
position: [clampedX, clampedY, 0],
rotation: [0, itemRotation, 0],
side,
parentId: event.node.id,
wallId: event.node.id,
})
if (currentWallId !== event.node.id) {
markWallDirty(currentWallId)
currentWallId = event.node.id
}
markWallDirty(event.node.id)
const valid = !hasWallChildOverlap(
event.node.id, clampedX, clampedY,
movingWindowNode.width, movingWindowNode.height,
movingWindowNode.id,
)
updateCursor(
wallLocalToWorld(event.node, clampedX, clampedY, getLevelYOffset(), getSlabElevation(event)),
cursorRotation,
valid,
)
event.stopPropagation()
}
const onWallClick = (event: WallEvent) => {
if (!isValidWallSideFace(event.normal)) return
// Only interact with walls on the current level
if (event.node.parentId !== getLevelId()) return
const side = getSideFromNormal(event.normal)
const itemRotation = calculateItemRotation(event.normal)
const localX = snapToHalf(event.localPosition[0])
const localY = snapToHalf(event.localPosition[1])
const { clampedX, clampedY } = clampToWall(
event.node, localX, localY,
movingWindowNode.width, movingWindowNode.height,
)
const valid = !hasWallChildOverlap(
event.node.id, clampedX, clampedY,
movingWindowNode.width, movingWindowNode.height,
movingWindowNode.id,
)
if (!valid) return
let placedId: string
if (isNew) {
// Duplicate mode: delete transient + resume + createNode
// Undo will remove the newly created node entirely
useScene.getState().deleteNode(movingWindowNode.id)
useScene.temporal.getState().resume()
const node = WindowNode.parse({
position: [clampedX, clampedY, 0],
rotation: [0, itemRotation, 0],
side,
wallId: event.node.id,
parentId: event.node.id,
width: movingWindowNode.width,
height: movingWindowNode.height,
frameThickness: movingWindowNode.frameThickness,
frameDepth: movingWindowNode.frameDepth,
columnRatios: movingWindowNode.columnRatios,
rowRatios: movingWindowNode.rowRatios,
columnDividerThickness: movingWindowNode.columnDividerThickness,
rowDividerThickness: movingWindowNode.rowDividerThickness,
sill: movingWindowNode.sill,
sillDepth: movingWindowNode.sillDepth,
sillThickness: movingWindowNode.sillThickness,
})
useScene.getState().createNode(node, event.node.id as AnyNodeId)
placedId = node.id
} else {
// Move mode: restore original (clean baseline) + resume + updateNode
// Undo will revert to the original position
useScene.getState().updateNode(movingWindowNode.id, {
position: original.position,
rotation: original.rotation,
side: original.side,
parentId: original.parentId,
wallId: original.wallId,
metadata: original.metadata,
})
useScene.temporal.getState().resume()
useScene.getState().updateNode(movingWindowNode.id, {
position: [clampedX, clampedY, 0],
rotation: [0, itemRotation, 0],
side,
parentId: event.node.id,
wallId: event.node.id,
metadata: {},
})
if (original.parentId && original.parentId !== event.node.id) {
markWallDirty(original.parentId)
}
placedId = movingWindowNode.id
}
markWallDirty(event.node.id)
useScene.temporal.getState().pause()
sfxEmitter.emit('sfx:item-place')
hideCursor()
useViewer.getState().setSelection({ selectedIds: [placedId] })
exitMoveMode()
event.stopPropagation()
}
const onWallLeave = () => {
hideCursor()
if (isNew) return // No original to restore for duplicates
// Move mode: restore to original position while off-wall
if (currentWallId && currentWallId !== original.parentId) {
markWallDirty(currentWallId)
}
currentWallId = original.parentId
useScene.getState().updateNode(movingWindowNode.id, {
position: original.position,
rotation: original.rotation,
side: original.side,
parentId: original.parentId,
wallId: original.wallId,
})
if (original.parentId) markWallDirty(original.parentId)
}
const onCancel = () => {
if (isNew) {
useScene.getState().deleteNode(movingWindowNode.id)
if (currentWallId) markWallDirty(currentWallId)
} else {
useScene.getState().updateNode(movingWindowNode.id, {
position: original.position,
rotation: original.rotation,
side: original.side,
parentId: original.parentId,
wallId: original.wallId,
metadata: original.metadata,
})
if (original.parentId) markWallDirty(original.parentId)
}
useScene.temporal.getState().resume()
hideCursor()
exitMoveMode()
}
emitter.on('wall:enter', onWallEnter)
emitter.on('wall:move', onWallMove)
emitter.on('wall:click', onWallClick)
emitter.on('wall:leave', onWallLeave)
emitter.on('tool:cancel', onCancel)
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 currentMeta = current?.metadata as Record<string, unknown> | undefined
if (currentMeta?.isTransient) {
if (isNew) {
useScene.getState().deleteNode(movingWindowNode.id)
if (currentWallId) markWallDirty(currentWallId)
} else {
useScene.getState().updateNode(movingWindowNode.id, {
position: original.position,
rotation: original.rotation,
side: original.side,
parentId: original.parentId,
wallId: original.wallId,
metadata: original.metadata,
})
if (original.parentId) markWallDirty(original.parentId)
}
}
useScene.temporal.getState().resume()
emitter.off('wall:enter', onWallEnter)
emitter.off('wall:move', onWallMove)
emitter.off('wall:click', onWallClick)
emitter.off('wall:leave', onWallLeave)
emitter.off('tool:cancel', onCancel)
}
}, [movingWindowNode])
const edgesGeo = useMemo(() => {
const boxGeo = new BoxGeometry(
movingWindowNode.width,
movingWindowNode.height,
movingWindowNode.frameDepth ?? 0.07,
)
const geo = new EdgesGeometry(boxGeo)
boxGeo.dispose()
return geo
}, [movingWindowNode])
return (
<group ref={cursorGroupRef} visible={false}>
<lineSegments geometry={edgesGeo} material={edgeMaterial} layers={EDITOR_LAYER} />
</group>
)
}
@@ -1,109 +0,0 @@
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.
* Wall XZ uses level-local coordinates (levels only offset in Y, not XZ).
* Pass levelYOffset (the level group's current world Y) and slabElevation (the
* wall mesh's Y within the level group) so the cursor lands at the correct world
* height — matching how WallSystem positions the wall mesh at slabElevation.
*/
export function wallLocalToWorld(
wallNode: WallNode,
localX: number,
localY: number,
levelYOffset = 0,
slabElevation = 0,
): [number, number, number] {
const wallAngle = Math.atan2(
wallNode.end[1] - wallNode.start[1],
wallNode.end[0] - wallNode.start[0],
)
return [
wallNode.start[0] + localX * Math.cos(wallAngle),
slabElevation + localY + levelYOffset,
wallNode.start[1] + localX * Math.sin(wallAngle),
]
}
/**
* Clamps window center position so it stays fully within wall bounds.
*/
export function clampToWall(
wallNode: WallNode,
localX: number,
localY: number,
width: number,
height: number,
): { clampedX: number; clampedY: number } {
const dx = wallNode.end[0] - wallNode.start[0]
const dz = wallNode.end[1] - wallNode.start[1]
const wallLength = Math.sqrt(dx * dx + dz * dz)
const wallHeight = wallNode.height ?? 2.5
const clampedX = Math.max(width / 2, Math.min(wallLength - width / 2, localX))
const clampedY = Math.max(height / 2, Math.min(wallHeight - height / 2, localY))
return { clampedX, clampedY }
}
/**
* Directly checks the wall's children for bounding-box overlap with a proposed window.
* Works for both `item` type (position[1] = bottom) and `window` type (position[1] = center).
* The spatial grid only tracks `item` nodes, so windows must be checked this way.
* Reads the wall's latest children from the store (not the event node) to avoid stale data.
*/
export function hasWallChildOverlap(
wallId: string,
clampedX: number,
clampedY: number,
width: number,
height: number,
ignoreId?: string,
): boolean {
const nodes = useScene.getState().nodes
const wallNode = nodes[wallId as AnyNodeId] as WallNode | undefined
if (!wallNode) return true // Block if wall not found
const halfW = width / 2
const halfH = height / 2
const newBottom = clampedY - halfH
const newTop = clampedY + halfH
const newLeft = clampedX - halfW
const newRight = clampedX + halfW
for (const childId of wallNode.children) {
if (childId === ignoreId) continue
const child = nodes[childId as AnyNodeId]
if (!child) continue
let childLeft: number, childRight: number, childBottom: number, childTop: number
if (child.type === 'item') {
const item = child as ItemNode
if (item.asset.attachTo !== 'wall' && item.asset.attachTo !== 'wall-side') continue
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
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
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
childTop = door.position[1] + door.height / 2
} else {
continue
}
const xOverlap = newLeft < childRight && newRight > childLeft
const yOverlap = newBottom < childTop && newTop > childBottom
if (xOverlap && yOverlap) return true
}
return false
}
@@ -1,276 +0,0 @@
import {
type AnyNodeId,
emitter,
sceneRegistry,
spatialGridManager,
useScene,
type WallEvent,
WindowNode,
} from '@pascal-app/core'
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 {
calculateCursorRotation,
calculateItemRotation,
getSideFromNormal,
isValidWallSideFace,
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)
linewidth: 3,
depthTest: false,
depthWrite: false,
})
/**
* Window tool — places WindowNodes on walls only.
* Shows a rectangle cursor (green = valid, red = invalid) matching window dimensions.
*/
export const WindowTool: React.FC = () => {
const draftRef = useRef<WindowNode | null>(null)
const cursorGroupRef = useRef<Group>(null!)
const edgesRef = useRef<LineSegments>(null!)
useEffect(() => {
useScene.temporal.getState().pause()
const getLevelId = () => useViewer.getState().selection.levelId
const getLevelYOffset = () => {
const id = getLevelId()
return id ? (sceneRegistry.nodes.get(id as AnyNodeId)?.position.y ?? 0) : 0
}
const getSlabElevation = (wallEvent: WallEvent) =>
spatialGridManager.getSlabElevationForWall(
wallEvent.node.parentId ?? '',
wallEvent.node.start,
wallEvent.node.end,
)
const markWallDirty = (wallId: string) => {
useScene.getState().dirtyNodes.add(wallId as AnyNodeId)
}
const destroyDraft = () => {
if (!draftRef.current) return
const wallId = draftRef.current.parentId
useScene.getState().deleteNode(draftRef.current.id)
draftRef.current = null
// Rebuild wall so it removes the cutout from the deleted draft
if (wallId) markWallDirty(wallId)
}
const hideCursor = () => {
if (cursorGroupRef.current) cursorGroupRef.current.visible = false
}
const updateCursor = (
worldPosition: [number, number, number],
cursorRotationY: number,
valid: boolean,
) => {
const group = cursorGroupRef.current
if (!group) return
group.visible = true
group.position.set(...worldPosition)
group.rotation.y = cursorRotationY
edgeMaterial.color.setHex(valid ? 0x22c55e : 0xef4444)
}
const onWallEnter = (event: WallEvent) => {
if (!isValidWallSideFace(event.normal)) return
const levelId = getLevelId()
if (!levelId) return
// Only interact with walls on the current level
if (event.node.parentId !== levelId) return
destroyDraft()
const side = getSideFromNormal(event.normal)
const itemRotation = calculateItemRotation(event.normal)
const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)
const localX = snapToHalf(event.localPosition[0])
const localY = snapToHalf(event.localPosition[1])
const width = 1.5
const height = 1.5
const { clampedX, clampedY } = clampToWall(event.node, localX, localY, width, height)
const node = WindowNode.parse({
position: [clampedX, clampedY, 0],
rotation: [0, itemRotation, 0],
side,
wallId: event.node.id,
parentId: event.node.id,
metadata: { isTransient: true },
})
useScene.getState().createNode(node, event.node.id as AnyNodeId)
draftRef.current = node
const valid = !hasWallChildOverlap(event.node.id, clampedX, clampedY, width, height, node.id)
updateCursor(
wallLocalToWorld(event.node, clampedX, clampedY, getLevelYOffset(), getSlabElevation(event)),
cursorRotation,
valid,
)
event.stopPropagation()
}
const onWallMove = (event: WallEvent) => {
if (!isValidWallSideFace(event.normal)) return
// Only interact with walls on the current level
if (event.node.parentId !== getLevelId()) return
const side = getSideFromNormal(event.normal)
const itemRotation = calculateItemRotation(event.normal)
const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)
const localX = snapToHalf(event.localPosition[0])
const localY = snapToHalf(event.localPosition[1])
const width = draftRef.current?.width ?? 1.5
const height = draftRef.current?.height ?? 1.5
const { clampedX, clampedY } = clampToWall(event.node, localX, localY, width, height)
if (draftRef.current) {
useScene.getState().updateNode(draftRef.current.id, {
position: [clampedX, clampedY, 0],
rotation: [0, itemRotation, 0],
side,
parentId: event.node.id,
wallId: event.node.id,
})
}
const valid = !hasWallChildOverlap(
event.node.id, clampedX, clampedY, width, height,
draftRef.current?.id,
)
updateCursor(
wallLocalToWorld(event.node, clampedX, clampedY, getLevelYOffset(), getSlabElevation(event)),
cursorRotation,
valid,
)
event.stopPropagation()
}
const onWallClick = (event: WallEvent) => {
if (!draftRef.current) return
if (!isValidWallSideFace(event.normal)) return
// Only interact with walls on the current level
if (event.node.parentId !== getLevelId()) return
const side = getSideFromNormal(event.normal)
const itemRotation = calculateItemRotation(event.normal)
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,
)
const valid = !hasWallChildOverlap(
event.node.id, clampedX, clampedY,
draftRef.current.width, draftRef.current.height,
draftRef.current.id,
)
if (!valid) return
const draft = draftRef.current
draftRef.current = null
// Delete transient draft (paused, invisible to undo)
useScene.getState().deleteNode(draft.id)
// Resume → create permanent node (single undoable action)
useScene.temporal.getState().resume()
const levelId = getLevelId()
const state = useScene.getState()
const windowCount = Object.values(state.nodes).filter((n) => {
if (n.type !== 'window') return false
const wall = n.parentId ? state.nodes[n.parentId as AnyNodeId] : undefined
return wall?.parentId === levelId
}).length
const name = `Window ${windowCount + 1}`
const node = WindowNode.parse({
name,
position: [clampedX, clampedY, 0],
rotation: [0, itemRotation, 0],
side,
wallId: event.node.id,
parentId: event.node.id,
width: draft.width,
height: draft.height,
frameThickness: draft.frameThickness,
frameDepth: draft.frameDepth,
columnRatios: draft.columnRatios,
rowRatios: draft.rowRatios,
columnDividerThickness: draft.columnDividerThickness,
rowDividerThickness: draft.rowDividerThickness,
sill: draft.sill,
sillDepth: draft.sillDepth,
sillThickness: draft.sillThickness,
})
useScene.getState().createNode(node, event.node.id as AnyNodeId)
useViewer.getState().setSelection({ selectedIds: [node.id] })
useScene.temporal.getState().pause()
sfxEmitter.emit('sfx:item-place')
event.stopPropagation()
}
const onWallLeave = () => {
destroyDraft()
hideCursor()
}
const onCancel = () => {
destroyDraft()
hideCursor()
}
emitter.on('wall:enter', onWallEnter)
emitter.on('wall:move', onWallMove)
emitter.on('wall:click', onWallClick)
emitter.on('wall:leave', onWallLeave)
emitter.on('tool:cancel', onCancel)
return () => {
destroyDraft()
hideCursor()
useScene.temporal.getState().resume()
emitter.off('wall:enter', onWallEnter)
emitter.off('wall:move', onWallMove)
emitter.off('wall:click', onWallClick)
emitter.off('wall:leave', onWallLeave)
emitter.off('tool:cancel', onCancel)
}
}, [])
// Cursor geometry: window outline rectangle (width × height × frameDepth)
const boxGeo = new BoxGeometry(1.5, 1.5, 0.07)
const edgesGeo = new EdgesGeometry(boxGeo)
boxGeo.dispose()
return (
<group ref={cursorGroupRef} visible={false}>
<lineSegments ref={edgesRef} geometry={edgesGeo} material={edgeMaterial} layers={EDITOR_LAYER} />
</group>
)
}
@@ -1,39 +0,0 @@
import { resolveLevelId, useScene, type ZoneNode } from '@pascal-app/core'
import { useCallback } from 'react'
import { PolygonEditor } from '../shared/polygon-editor'
interface ZoneBoundaryEditorProps {
zoneId: ZoneNode['id']
}
/**
* Zone boundary editor - allows editing zone polygon vertices for a specific zone
* Uses the generic PolygonEditor component
*/
export const ZoneBoundaryEditor: React.FC<ZoneBoundaryEditorProps> = ({ zoneId }) => {
const zoneNode = useScene((state) => state.nodes[zoneId])
const updateNode = useScene((state) => state.updateNode)
const zone = zoneNode?.type === 'zone' ? (zoneNode as ZoneNode) : null
const handlePolygonChange = useCallback(
(newPolygon: Array<[number, number]>) => {
updateNode(zoneId, { polygon: newPolygon })
},
[zoneId, updateNode],
)
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)}
/>
)
}
@@ -1,360 +0,0 @@
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";
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]
): [number, number] => {
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);
// Calculate distances to horizontal, vertical, and diagonal lines
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);
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];
}
};
/**
* Creates a zone with the given polygon points
*/
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}`;
// Cycle through colors
const color = PALETTE_COLORS[zoneCount % PALETTE_COLORS.length];
const zone = ZoneNode.parse({
name,
polygon: points,
color,
});
createNode(zone, levelId);
// Select the newly created zone
useViewer.getState().setSelection({ zoneId: zone.id });
};
type PreviewState = {
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]);
};
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);
// Preview state for reactive rendering (for shape and point markers)
const [preview, setPreview] = useState<PreviewState>({
points: [],
cursorPoint: null,
levelY: 0,
});
useEffect(() => {
if (!currentLevelId) return;
let cursorPosition: [number, number] = [0, 0];
// Initialize line geometries
mainLineRef.current.geometry = new BufferGeometry();
closingLineRef.current.geometry = new BufferGeometry();
const updateLines = () => {
const points = pointsRef.current;
const y = levelYRef.current + Y_OFFSET;
if (points.length === 0) {
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)
);
// Add cursor point
const lastPoint = points[points.length - 1];
if (lastPoint) {
const snapped = calculateSnapPoint(lastPoint, cursorPosition);
if (isValidPoint(snapped)) {
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;
} else {
mainLineRef.current.visible = false;
}
// Update closing line (from cursor back to first point)
const firstPoint = points[0];
if (points.length >= 2 && lastPoint && isValidPoint(firstPoint)) {
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;
}
} else {
closingLineRef.current.visible = false;
}
};
const updatePreview = () => {
const points = pointsRef.current;
const lastPoint = points[points.length - 1];
let cursorPt: [number, number] | null = null;
if (lastPoint) {
cursorPt = calculateSnapPoint(lastPoint, cursorPosition);
} else if (points.length === 0) {
cursorPt = cursorPosition;
}
setPreview({ points: [...points], cursorPoint: cursorPt, levelY: levelYRef.current });
updateLines();
};
const onGridMove = (event: GridEvent) => {
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];
// If we have points, snap to axis from last point
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]);
} else {
cursorRef.current.position.set(gridX, event.position[1], gridZ);
}
updatePreview();
};
const onGridClick = (event: GridEvent) => {
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];
// Snap to axis from last point
const lastPoint = pointsRef.current[pointsRef.current.length - 1];
if (lastPoint) {
clickPoint = calculateSnapPoint(lastPoint, clickPoint);
}
// Check if clicking on the first point to close the shape
const firstPoint = pointsRef.current[0];
if (
pointsRef.current.length >= 3 &&
firstPoint &&
Math.abs(clickPoint[0] - firstPoint[0]) < 0.25 &&
Math.abs(clickPoint[1] - firstPoint[1]) < 0.25
) {
// Create the zone
commitZoneDrawing(currentLevelId, pointsRef.current);
// Reset state
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();
}
};
const onGridDoubleClick = (_event: GridEvent) => {
if (!currentLevelId) return;
// Need at least 3 points to form a polygon
if (pointsRef.current.length >= 3) {
commitZoneDrawing(currentLevelId, pointsRef.current);
// Reset state
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);
return () => {
emitter.off("grid:move", onGridMove);
emitter.off("grid:click", onGridClick);
emitter.off("grid:double-click", onGridDoubleClick);
// Reset state on unmount
pointsRef.current = [];
};
}, [currentLevelId, setTool]);
const { points, cursorPoint, levelY } = preview;
// Create preview shape when we have 3+ points
const previewShape = useMemo(() => {
if (points.length < 3) return null;
const allPoints = [...points];
if (isValidPoint(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 shape = new Shape();
shape.moveTo(firstPt[0], -firstPt[1]);
for (let i = 1; i < allPoints.length; i++) {
const pt = allPoints[i];
if (isValidPoint(pt)) {
shape.lineTo(pt[0], -pt[1]);
}
}
shape.closePath();
return shape;
}, [points, cursorPoint]);
return (
<group>
{/* Cursor */}
<CursorSphere ref={cursorRef} />
{/* Preview fill */}
{previewShape && (
<mesh
frustumCulled={false}
layers={EDITOR_LAYER}
position={[0, levelY + Y_OFFSET, 0]}
rotation={[-Math.PI / 2, 0, 0]}
>
<shapeGeometry args={[previewShape]} />
<meshBasicMaterial
color="#818cf8"
depthTest={false}
opacity={0.15}
side={DoubleSide}
transparent
/>
</mesh>
)}
{/* Main line - uses native line element with TSL-compatible material */}
{/* @ts-ignore */}
<line ref={mainLineRef} frustumCulled={false} renderOrder={1} visible={false} layers={EDITOR_LAYER}>
<bufferGeometry />
<lineBasicNodeMaterial
color="#818cf8"
linewidth={3}
depthTest={false}
depthWrite={false}
/>
</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}>
<bufferGeometry />
<lineBasicNodeMaterial
color="#818cf8"
linewidth={2}
depthTest={false}
depthWrite={false}
opacity={0.5}
transparent
/>
</line>
{/* 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
)}
</group>
);
};
@@ -1,62 +0,0 @@
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";
interface ActionButtonProps extends React.ComponentProps<typeof Button> {
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
) => {
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
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"
)}
>
{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">
{shortcut}
</span>
</div>
)}
</Button>
</TooltipTrigger>
<TooltipContent side={tooltipSide}>
{tooltipContent || (
<p>
{label} {shortcut && `(${shortcut})`}
</p>
)}
</TooltipContent>
</Tooltip>
);
}
);
ActionButton.displayName = "ActionButton";
@@ -1,74 +0,0 @@
'use client'
import { emitter } from '@pascal-app/core'
import Image from 'next/image'
import { ActionButton } from "./action-button";
export function CameraActions() {
const goToTopView = () => {
emitter.emit('camera-controls:top-view')
}
const orbitCW = () => {
emitter.emit('camera-controls:orbit-cw')
}
const orbitCCW = () => {
emitter.emit('camera-controls:orbit-ccw')
}
return (
<div className="flex items-center gap-1">
{/* Orbit CCW */}
<ActionButton
label="Orbit Left"
className="group hover:bg-white/5"
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"
height={28}
src="/icons/rotate.png"
width={28}
/>
</ActionButton>
{/* Orbit CW */}
<ActionButton
label="Orbit Right"
className="group hover:bg-white/5"
onClick={orbitCW}
size="icon"
variant="ghost"
>
<Image
alt="Orbit Right"
className="h-[28px] w-[28px] object-contain opacity-70 transition-opacity group-hover:opacity-100"
height={28}
src="/icons/rotate.png"
width={28}
/>
</ActionButton>
{/* Top View */}
<ActionButton
label="Top View"
className="group hover:bg-white/5"
onClick={goToTopView}
size="icon"
variant="ghost"
>
<Image
alt="Top View"
className="h-[28px] w-[28px] object-contain opacity-70 transition-opacity group-hover:opacity-100"
height={28}
src="/icons/topview.png"
width={28}
/>
</ActionButton>
</div>
)
}
@@ -1,136 +0,0 @@
"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";
type ModeConfig = {
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: "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",
},
{
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",
icon: Trash2,
label: "Delete",
shortcut: "D",
color: "hover:bg-red-500/20 hover:text-red-400",
activeColor: "bg-red-500/20 text-red-400",
},
// {
// id: 'painting',
// icon: Paintbrush,
// label: 'Painting',
// shortcut: 'P',
// color: 'hover:bg-cyan-500/20 hover:text-cyan-400',
// activeColor: 'bg-cyan-500/20 text-cyan-400',
// },
// {
// id: 'guide',
// icon: Image,
// label: 'Guide',
// shortcut: 'G',
// 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"],
};
export function ControlModes() {
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 handleModeClick = (mode: 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);
return (
<ActionButton
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)}
size="icon"
variant="ghost"
>
{m.imageSrc ? (
<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"
)}
height={28}
src={m.imageSrc}
width={28}
/>
) : (
Icon && <Icon className="h-5 w-5" />
)}
</ActionButton>
);
})}
</div>
);
}
@@ -1,105 +0,0 @@
"use client";
import NextImage from "next/image";
import { ActionButton } from "./action-button";
import { cn } from "@/lib/utils";
import useEditor, { CatalogCategory } from "@/store/use-editor";
export type FurnishToolConfig = {
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/appliance.png",
label: "Appliance",
catalogCategory: "appliance",
},
{
id: "item",
iconSrc: "/icons/kitchen.png",
label: "Kitchen",
catalogCategory: "kitchen",
},
{
id: "item",
iconSrc: "/icons/bathroom.png",
label: "Bathroom",
catalogCategory: "bathroom",
},
{
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 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;
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",
)}
onClick={() => {
if (!isActive) {
setCatalogCategory(tool.catalogCategory);
setActiveTool("item");
if (mode !== "build") {
setMode("build");
}
}
}}
size="icon"
variant="ghost"
>
<NextImage
alt={tool.label}
className="size-full object-contain"
height={28}
src={tool.iconSrc}
width={28}
/>
</ActionButton>
);
})}
</div>
);
}
@@ -1,157 +0,0 @@
"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";
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 transition = reducedMotion
? { duration: 0 }
: { 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",
className,
)}
>
{/* Item Catalog Row - Only show when in build mode with item tool */}
<AnimatePresence>
{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,
paddingTop: 8,
paddingBottom: 8,
borderBottomWidth: 1,
}}
exit={{
opacity: 0,
maxHeight: 0,
paddingTop: 0,
paddingBottom: 0,
borderBottomWidth: 0,
}}
transition={transition}
>
<ItemCatalog key={catalogCategory} category={catalogCategory} />
</motion.div>
)}
</AnimatePresence>
<AnimatePresence>
{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,
paddingTop: 8,
paddingBottom: 8,
borderBottomWidth: 1,
}}
exit={{
opacity: 0,
maxHeight: 0,
paddingTop: 0,
paddingBottom: 0,
borderBottomWidth: 0,
}}
transition={transition}
>
<div className="mx-auto w-max">
<FurnishTools />
</div>
</motion.div>
)}
</AnimatePresence>
{/* Structure Tools Row - Animated */}
<AnimatePresence>
{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,
paddingTop: 8,
paddingBottom: 8,
borderBottomWidth: 1,
}}
exit={{
opacity: 0,
maxHeight: 0,
paddingTop: 0,
paddingBottom: 0,
borderBottomWidth: 0,
}}
transition={transition}
>
<div className="w-max">
<StructureTools />
</div>
</motion.div>
)}
</AnimatePresence>
{/* Control Mode Row - Always visible, centered */}
<div className="flex items-center justify-center gap-1 px-2 py-1.5">
<ControlModes />
<div className="mx-1 h-5 w-px bg-border" />
<ViewToggles />
<div className="mx-1 h-5 w-px bg-border" />
<CameraActions />
</div>
</motion.div>
</TooltipProvider>
);
}
@@ -1,88 +0,0 @@
'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'
export type ToolConfig = {
id: StructureTool; iconSrc: string; label: string; catalogCategory?: CatalogCategory }
export const tools: ToolConfig[] = [
{ id: 'wall', iconSrc: '/icons/wall.png', label: 'Wall' },
// { id: 'room', iconSrc: '/icons/room.png', label: 'Room' },
// { id: 'custom-room', iconSrc: '/icons/custom-room.png', label: 'Custom Room' },
{ id: 'slab', iconSrc: '/icons/floor.png', label: 'Slab' },
{ id: 'ceiling', iconSrc: '/icons/ceiling.png', label: 'Ceiling' },
{ id: 'roof', iconSrc: '/icons/roof.png', label: 'Gable Roof' },
{ id: 'door', iconSrc: '/icons/door.png', label: 'Door' },
{ id: 'window', iconSrc: '/icons/window.png', label: 'Window' },
{ id: 'zone', iconSrc: '/icons/zone.png', label: 'Zone' },
]
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 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 hasActiveTool = visibleTools.some((t) =>
activeTool === t.id &&
(t.catalogCategory ? catalogCategory === t.catalogCategory : true)
)
return (
<div className="flex items-center gap-1.5 px-1">
{visibleTools.map((tool, index) => {
// For item tools with catalog category, check both tool and category match
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',
)}
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')
}
}
}}
size="icon"
variant="ghost"
>
<NextImage
alt={tool.label}
className="size-full object-contain"
height={28}
src={tool.iconSrc}
width={28}
/>
</ActionButton>
)
})}
</div>
)
}
@@ -1,162 +0,0 @@
'use client'
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'
const levelModeLabels: Record<'stacked' | 'exploded' | 'solo', string> = {
stacked: 'Stacked',
exploded: 'Exploded',
solo: 'Solo',
}
const levelModeOrder: ('stacked' | 'exploded' | 'solo')[] = ['stacked', 'exploded', 'solo']
type WallMode = 'up' | 'cutaway' | 'down'
const wallModeConfig: Record<
WallMode,
{ icon: React.FC<React.ComponentProps<'img'>>; label: string }
> = {
up: {
icon: (props) => (
<img alt="Full Height" height={20} src="/icons/room.png" width={20} {...props} />
),
label: 'Full Height',
},
cutaway: {
icon: (props) => (
<img alt="Cutaway" height={20} src="/icons/wallcut.png" width={20} {...props} />
),
label: 'Cutaway',
},
down: {
icon: (props) => <img alt="Low" height={20} src="/icons/walllow.png" width={20} {...props} />,
label: 'Low',
},
}
const wallModeOrder: WallMode[] = ['cutaway', 'up', 'down']
export function ViewToggles() {
const cameraMode = useViewer((state) => state.cameraMode)
const setCameraMode = useViewer((state) => state.setCameraMode)
const levelMode = useViewer((state) => state.levelMode)
const setLevelMode = useViewer((state) => state.setLevelMode)
const wallMode = useViewer((state) => state.wallMode)
const setWallMode = useViewer((state) => state.setWallMode)
const showScans = useViewer((state) => state.showScans)
const setShowScans = useViewer((state) => state.setShowScans)
const showGuides = useViewer((state) => state.showGuides)
const setShowGuides = useViewer((state) => state.setShowGuides)
const toggleCameraMode = () => {
setCameraMode(cameraMode === 'perspective' ? 'orthographic' : 'perspective')
}
const cycleLevelMode = () => {
if (levelMode === 'manual') {
setLevelMode('stacked')
return
}
const currentIndex = levelModeOrder.indexOf(levelMode as 'stacked' | 'exploded' | 'solo')
const nextIndex = (currentIndex + 1) % levelModeOrder.length
const nextMode = levelModeOrder[nextIndex]
if (nextMode) setLevelMode(nextMode)
}
const cycleWallMode = () => {
const currentIndex = wallModeOrder.indexOf(wallMode)
const nextIndex = (currentIndex + 1) % wallModeOrder.length
const nextMode = wallModeOrder[nextIndex]
if (nextMode) setWallMode(nextMode)
}
return (
<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',
)}
onClick={toggleCameraMode}
size="icon"
variant="ghost"
>
<Camera className="h-6 w-6" />
</ActionButton>
{/* 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',
)}
onClick={cycleLevelMode}
size="icon"
variant="ghost"
>
{levelMode === 'solo' && <Diamond className="h-6 w-6" />}
{levelMode === 'exploded' && <Layers2 className="h-6 w-6" />}
{(levelMode === 'stacked' || levelMode === 'manual') && <Layers className="h-6 w-6" />}
</ActionButton>
{/* 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',
)}
onClick={cycleWallMode}
size="icon"
variant="ghost"
>
{(() => {
const Icon = wallModeConfig[wallMode].icon
return <Icon className="h-[28px] w-[28px]" />
})()}
</ActionButton>
{/* 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',
)}
onClick={() => setShowScans(!showScans)}
size="icon"
variant="ghost"
>
<img alt="Scans" className="h-[28px] w-[28px] object-contain" src="/icons/mesh.png" />
</ActionButton>
{/* 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',
)}
onClick={() => setShowGuides(!showGuides)}
size="icon"
variant="ghost"
>
<img alt="Guides" className="h-[28px] w-[28px] object-contain" src="/icons/floorplan.png" />
</ActionButton>
</div>
)
}
@@ -1,772 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import { Command } from "cmdk";
import { create } from "zustand";
import {
AppWindow,
ArrowRight,
Building2,
Camera,
ChevronRight,
Copy,
DoorOpen,
Eye,
EyeOff,
FileJson,
Hexagon,
Layers,
Map,
Maximize2,
Minimize2,
Moon,
MousePointer2,
Package,
PencilLine,
Plus,
Redo2,
Search,
Square,
SquareStack,
Sun,
Trash2,
Undo2,
Video,
Box,
Grid3X3,
} from "lucide-react";
import { Dialog, DialogContent } from "@/components/ui/primitives/dialog";
import useEditor from "@/store/use-editor";
import type { StructureTool } from "@/store/use-editor";
import { useViewer } from "@pascal-app/viewer";
import { emitter, LevelNode, useScene } from "@pascal-app/core";
import type { AnyNodeId } from "@pascal-app/core";
import { useShallow } from "zustand/shallow";
// ---------------------------------------------------------------------------
// Open-state store — imported by icon-rail to trigger the palette
// ---------------------------------------------------------------------------
interface CommandPaletteStore {
open: boolean;
setOpen: (open: boolean) => void;
}
export const useCommandPalette = create<CommandPaletteStore>((set) => ({
open: false,
setOpen: (open) => set({ open }),
}));
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function Shortcut({ keys }: { keys: string[] }) {
return (
<span className="ml-auto flex items-center gap-0.5 shrink-0">
{keys.map((k) => (
<kbd
key={k}
className="flex items-center justify-center rounded border border-border/60 bg-muted/60 px-1 py-0.5 text-[10px] leading-none text-muted-foreground min-w-4.5"
>
{k}
</kbd>
))}
</span>
);
}
function Item({
icon,
label,
onSelect,
shortcut,
disabled = false,
keywords = [],
badge,
navigate = false,
}: {
icon: React.ReactNode;
label: string;
onSelect: () => void;
shortcut?: string[];
disabled?: boolean;
keywords?: string[];
badge?: string;
navigate?: boolean;
}) {
return (
<Command.Item
value={label}
keywords={keywords}
onSelect={onSelect}
disabled={disabled}
className="flex cursor-pointer items-center gap-2.5 rounded-md px-2.5 py-2 text-sm text-foreground transition-colors data-[selected=true]:bg-accent data-[disabled=true]:cursor-not-allowed data-[disabled=true]:opacity-40"
>
<span className="flex h-4 w-4 shrink-0 items-center justify-center text-muted-foreground">
{icon}
</span>
<span className="flex-1 truncate">{label}</span>
{badge && (
<span className="rounded bg-muted px-1.5 py-0.5 text-[11px] text-muted-foreground">
{badge}
</span>
)}
{shortcut && <Shortcut keys={shortcut} />}
{(badge || navigate) && <ChevronRight className="h-3 w-3 shrink-0 text-muted-foreground" />}
</Command.Item>
);
}
function OptionItem({
label,
isActive = false,
onSelect,
icon,
disabled = false,
}: {
label: string;
isActive?: boolean;
onSelect: () => void;
icon?: React.ReactNode;
disabled?: boolean;
}) {
return (
<Command.Item
value={label}
onSelect={onSelect}
disabled={disabled}
className="flex cursor-pointer items-center gap-2.5 rounded-md px-2.5 py-2 text-sm text-foreground transition-colors data-[selected=true]:bg-accent data-[disabled=true]:cursor-not-allowed data-[disabled=true]:opacity-40"
>
<span className="flex h-4 w-4 shrink-0 items-center justify-center text-muted-foreground">
{isActive
? <div className="h-1.5 w-1.5 rounded-full bg-primary" />
: icon
}
</span>
<span className="flex-1 truncate">{label}</span>
</Command.Item>
);
}
// ---------------------------------------------------------------------------
// Sub-page label map
// ---------------------------------------------------------------------------
const PAGE_LABEL: Record<string, string> = {
"wall-mode": "Wall Mode",
"level-mode": "Level Mode",
"rename-level": "Rename Level",
"goto-level": "Go to Level",
"camera-view": "Camera Snapshot",
"camera-scope": "", // dynamic — overridden in breadcrumb
};
// ---------------------------------------------------------------------------
// Main component
// ---------------------------------------------------------------------------
export function CommandPalette() {
const { open, setOpen } = useCommandPalette();
const [meta, setMeta] = useState("⌘");
const [isFullscreen, setIsFullscreen] = useState(false);
const [pages, setPages] = useState<string[]>([]);
const [inputValue, setInputValue] = useState("");
const [cameraScope, setCameraScope] = useState<{ nodeId: string; label: string } | null>(null);
const page = pages[pages.length - 1];
const { setPhase, setMode, setTool, setStructureLayer, isPreviewMode, setPreviewMode } =
useEditor();
const cameraMode = useViewer((s) => s.cameraMode);
const setCameraMode = useViewer((s) => s.setCameraMode);
const levelMode = useViewer((s) => s.levelMode);
const setLevelMode = useViewer((s) => s.setLevelMode);
const wallMode = useViewer((s) => s.wallMode);
const setWallMode = useViewer((s) => s.setWallMode);
const theme = useViewer((s) => s.theme);
const setTheme = useViewer((s) => s.setTheme);
const selection = useViewer((s) => s.selection);
const exportScene = useViewer((s) => s.exportScene);
const activeLevelId = selection.levelId;
const activeLevelNode = useScene((s) => activeLevelId ? s.nodes[activeLevelId] : null);
const isLevelZero =
activeLevelNode?.type === "level" && (activeLevelNode as LevelNode).level === 0;
// Reactive snapshot status for the selected camera scope
const cameraScopeNode = useScene((s) => cameraScope ? s.nodes[cameraScope.nodeId as AnyNodeId] : null);
const hasScopeSnapshot = !!(cameraScopeNode as any)?.camera;
const allLevels = useScene(
useShallow((s) =>
(Object.values(s.nodes).filter((n) => n.type === "level") as LevelNode[]).sort(
(a, b) => a.level - b.level
)
)
);
const hasSelection = selection.selectedIds.length > 0;
// Platform detection
useEffect(() => {
setMeta(/Mac|iPhone|iPad|iPod/.test(navigator.platform) ? "⌘" : "Ctrl");
}, []);
// Fullscreen tracking
useEffect(() => {
const handler = () => setIsFullscreen(!!document.fullscreenElement);
document.addEventListener("fullscreenchange", handler);
return () => document.removeEventListener("fullscreenchange", handler);
}, []);
// Cmd/Ctrl+K global shortcut
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
e.preventDefault();
setOpen(true);
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [setOpen]);
// Reset sub-pages when palette closes
useEffect(() => {
if (!open) {
setPages([]);
setInputValue("");
setCameraScope(null);
}
}, [open]);
// ---------------------------------------------------------------------------
// Navigation helpers
// ---------------------------------------------------------------------------
const goBack = () => {
const leavingPage = pages[pages.length - 1];
if (leavingPage === "camera-scope") setCameraScope(null);
setPages((p) => p.slice(0, -1));
setInputValue("");
};
const navigateTo = (p: string) => {
// Pre-fill the rename input with the current level name
if (p === "rename-level" && activeLevelId) {
const level = useScene.getState().nodes[activeLevelId] as LevelNode;
setInputValue(level?.name ?? "");
} else {
setInputValue("");
}
setPages((prev) => [...prev, p]);
};
const navigateToCameraScope = (nodeId: string, label: string) => {
setCameraScope({ nodeId, label });
setInputValue("");
setPages((prev) => [...prev, "camera-scope"]);
};
// ---------------------------------------------------------------------------
// Action helpers
// ---------------------------------------------------------------------------
const run = (fn: () => void) => {
fn();
setOpen(false);
};
const activateTool = (tool: StructureTool) => {
run(() => {
setPhase("structure");
setMode("build");
if (tool === "zone") setStructureLayer("zones");
setTool(tool);
});
};
const wallModeLabel: Record<"cutaway" | "up" | "down", string> = { cutaway: "Cutaway", up: "Up", down: "Down" };
const levelModeLabel: Record<"manual" | "stacked" | "exploded" | "solo", string> = {
manual: "Manual",
stacked: "Stacked",
exploded: "Exploded",
solo: "Solo",
};
const deleteSelection = () => {
if (!hasSelection) return;
run(() => {
useScene.getState().deleteNodes(selection.selectedIds as any[]);
});
};
// Level management
const addLevel = () =>
run(() => {
const { nodes } = useScene.getState();
const building = Object.values(nodes).find((n) => n.type === "building");
if (!building) return;
const newLevel = LevelNode.parse({
level: building.children.length,
children: [],
parentId: building.id,
});
useScene.getState().createNode(newLevel, building.id);
useViewer.getState().setSelection({ levelId: newLevel.id });
});
const deleteActiveLevel = () => {
if (!activeLevelId || isLevelZero) return;
run(() => {
useScene.getState().deleteNode(activeLevelId as AnyNodeId);
const { nodes } = useScene.getState();
const level0 = Object.values(nodes).find(
(n) => n.type === "level" && (n as LevelNode).level === 0
);
if (level0) useViewer.getState().setSelection({ levelId: level0.id as `level_${string}` });
});
};
const confirmRename = () => {
if (!activeLevelId || !inputValue.trim()) return;
run(() => {
useScene.getState().updateNode(activeLevelId as AnyNodeId, { name: inputValue.trim() } as any);
});
};
// Camera snapshot (scoped to the currently selected camera scope)
const takeSnapshot = () => {
if (!cameraScope) return;
run(() => emitter.emit("camera-controls:capture", { nodeId: cameraScope.nodeId as AnyNodeId }));
};
const viewSnapshot = () => {
if (!cameraScope || !hasScopeSnapshot) return;
run(() => emitter.emit("camera-controls:view", { nodeId: cameraScope.nodeId as AnyNodeId }));
};
const clearSnapshot = () => {
if (!cameraScope || !hasScopeSnapshot) return;
run(() => {
useScene.getState().updateNode(cameraScope.nodeId as AnyNodeId, { camera: undefined } as any);
});
};
// Export helpers
const exportJson = () =>
run(() => {
const { nodes, rootNodeIds } = useScene.getState();
const blob = new Blob([JSON.stringify({ nodes, rootNodeIds }, null, 2)], {
type: "application/json",
});
const url = URL.createObjectURL(blob);
const a = Object.assign(document.createElement("a"), {
href: url,
download: `scene_${new Date().toISOString().split("T")[0]}.json`,
});
a.click();
URL.revokeObjectURL(url);
});
const copyShareLink = () =>
run(() => {
navigator.clipboard.writeText(window.location.href);
});
const takeScreenshot = () =>
run(() => {
const canvas = document.querySelector("canvas");
if (!canvas) return;
const a = Object.assign(document.createElement("a"), {
href: canvas.toDataURL("image/png"),
download: `screenshot_${new Date().toISOString().split("T")[0]}.png`,
});
a.click();
});
const toggleFullscreen = () =>
run(() => {
if (document.fullscreenElement) {
document.exitFullscreen();
} else {
document.documentElement.requestFullscreen();
}
});
// ---------------------------------------------------------------------------
// Render
// ---------------------------------------------------------------------------
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent
showCloseButton={false}
className="p-0 gap-0 max-w-lg overflow-hidden"
>
<Command
shouldFilter={page !== "rename-level"}
className="**:[[cmdk-group-heading]]:px-2.5 **:[[cmdk-group-heading]]:pb-1 **:[[cmdk-group-heading]]:pt-3 **:[[cmdk-group-heading]]:text-[10px] **:[[cmdk-group-heading]]:font-semibold **:[[cmdk-group-heading]]:uppercase **:[[cmdk-group-heading]]:tracking-wider **:[[cmdk-group-heading]]:text-muted-foreground"
onKeyDown={(e) => {
if (e.key === "Backspace" && !inputValue && pages.length > 0) {
e.preventDefault();
goBack();
}
}}
>
{/* Search bar */}
<div className="flex items-center border-b border-border/50 px-3">
<Search className="mr-2 h-4 w-4 shrink-0 text-muted-foreground" />
{page && (
<button
type="button"
onClick={goBack}
className="mr-2 shrink-0 rounded bg-muted px-1.5 py-0.5 text-[11px] text-muted-foreground hover:bg-muted/70 transition-colors"
>
{page === "camera-scope"
? (cameraScope?.label ?? "Snapshot")
: (PAGE_LABEL[page] ?? page)}
</button>
)}
<Command.Input
value={inputValue}
onValueChange={setInputValue}
className="flex h-12 w-full bg-transparent text-sm outline-none placeholder:text-muted-foreground"
placeholder={
page === "rename-level"
? "Type a new name…"
: page
? "Filter options…"
: "Search actions…"
}
/>
</div>
<Command.List className="max-h-100 overflow-y-auto p-1.5">
<Command.Empty className="py-8 text-center text-sm text-muted-foreground">
No commands found.
</Command.Empty>
{/* ── Root view ─────────────────────────────────────────────── */}
{!page && (
<>
{/* Scene / Tools */}
<Command.Group heading="Scene">
<Item icon={<Square className="h-4 w-4" />} label="Wall Tool" onSelect={() => activateTool("wall")} keywords={["draw", "build", "structure"]} />
<Item icon={<Layers className="h-4 w-4" />} label="Slab Tool" onSelect={() => activateTool("slab")} keywords={["floor", "build"]} />
<Item icon={<Grid3X3 className="h-4 w-4" />} label="Ceiling Tool" onSelect={() => activateTool("ceiling")} keywords={["top", "build"]} />
<Item icon={<DoorOpen className="h-4 w-4" />} label="Door Tool" onSelect={() => activateTool("door")} keywords={["opening", "entrance"]} />
<Item icon={<AppWindow className="h-4 w-4" />} label="Window Tool" onSelect={() => activateTool("window")} keywords={["opening", "glass"]} />
<Item icon={<Package className="h-4 w-4" />} label="Item Tool" onSelect={() => activateTool("item")} keywords={["furniture", "object", "asset", "furnish"]} />
<Item icon={<Hexagon className="h-4 w-4" />} label="Zone Tool" onSelect={() => activateTool("zone")} keywords={["area", "room", "space"]} />
<Item
icon={<Trash2 className="h-4 w-4" />}
label="Delete Selection"
onSelect={deleteSelection}
disabled={!hasSelection}
shortcut={["⌫"]}
keywords={["remove", "erase"]}
/>
</Command.Group>
{/* Levels */}
<Command.Group heading="Levels">
<Item
icon={<ArrowRight className="h-4 w-4" />}
label="Go to Level"
navigate
onSelect={() => navigateTo("goto-level")}
disabled={allLevels.length === 0}
keywords={["level", "floor", "go", "navigate", "switch", "select"]}
/>
<Item
icon={<Plus className="h-4 w-4" />}
label="Add Level"
onSelect={addLevel}
keywords={["level", "floor", "add", "create", "new"]}
/>
<Item
icon={<PencilLine className="h-4 w-4" />}
label="Rename Level"
navigate
onSelect={() => navigateTo("rename-level")}
disabled={!activeLevelId}
keywords={["level", "floor", "rename", "name"]}
/>
<Item
icon={<Trash2 className="h-4 w-4" />}
label="Delete Level"
onSelect={deleteActiveLevel}
disabled={!activeLevelId || isLevelZero}
keywords={["level", "floor", "delete", "remove"]}
/>
</Command.Group>
{/* Viewer Controls */}
<Command.Group heading="Viewer Controls">
<Item
icon={<Layers className="h-4 w-4" />}
label="Wall Mode"
badge={wallModeLabel[wallMode]}
onSelect={() => navigateTo("wall-mode")}
keywords={["wall", "cutaway", "up", "down", "view"]}
/>
<Item
icon={<SquareStack className="h-4 w-4" />}
label="Level Mode"
badge={levelModeLabel[levelMode]}
onSelect={() => navigateTo("level-mode")}
keywords={["level", "floor", "exploded", "stacked", "solo"]}
/>
<Item
icon={<Video className="h-4 w-4" />}
label={`Camera: Switch to ${cameraMode === "perspective" ? "Orthographic" : "Perspective"}`}
onSelect={() =>
run(() =>
setCameraMode(
cameraMode === "perspective" ? "orthographic" : "perspective"
)
)
}
keywords={["camera", "ortho", "perspective", "2d", "3d", "view"]}
/>
<Item
icon={theme === "dark" ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
label={theme === "dark" ? "Switch to Light Theme" : "Switch to Dark Theme"}
onSelect={() => run(() => setTheme(theme === "dark" ? "light" : "dark"))}
keywords={["theme", "dark", "light", "appearance", "color"]}
/>
<Item
icon={<Camera className="h-4 w-4" />}
label="Camera Snapshot"
navigate
onSelect={() => navigateTo("camera-view")}
keywords={["camera", "snapshot", "capture", "save", "view", "bookmark"]}
/>
</Command.Group>
{/* View / Mode */}
<Command.Group heading="View">
<Item
icon={isPreviewMode ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
label={isPreviewMode ? "Exit Preview" : "Enter Preview"}
onSelect={() => run(() => setPreviewMode(!isPreviewMode))}
keywords={["preview", "view", "read-only", "present"]}
/>
<Item
icon={
isFullscreen ? (
<Minimize2 className="h-4 w-4" />
) : (
<Maximize2 className="h-4 w-4" />
)
}
label={isFullscreen ? "Exit Fullscreen" : "Enter Fullscreen"}
onSelect={toggleFullscreen}
keywords={["fullscreen", "maximize", "expand", "window"]}
/>
</Command.Group>
{/* History */}
<Command.Group heading="History">
<Item
icon={<Undo2 className="h-4 w-4" />}
label="Undo"
onSelect={() => run(() => useScene.temporal.getState().undo())}
shortcut={[meta, "Z"]}
keywords={["undo", "revert", "back"]}
/>
<Item
icon={<Redo2 className="h-4 w-4" />}
label="Redo"
onSelect={() => run(() => useScene.temporal.getState().redo())}
shortcut={[meta, "⇧", "Z"]}
keywords={["redo", "forward", "repeat"]}
/>
</Command.Group>
{/* Export / Share */}
<Command.Group heading="Export & Share">
<Item
icon={<FileJson className="h-4 w-4" />}
label="Export Scene (JSON)"
onSelect={exportJson}
keywords={["export", "download", "json", "save", "data"]}
/>
{exportScene && (
<Item
icon={<Box className="h-4 w-4" />}
label="Export 3D Model (GLB)"
onSelect={() => run(() => exportScene())}
keywords={["export", "glb", "gltf", "3d", "model", "download"]}
/>
)}
<Item
icon={<Copy className="h-4 w-4" />}
label="Copy Share Link"
onSelect={copyShareLink}
keywords={["share", "copy", "url", "link"]}
/>
<Item
icon={<Camera className="h-4 w-4" />}
label="Take Screenshot"
onSelect={takeScreenshot}
keywords={["screenshot", "capture", "image", "photo", "png"]}
/>
</Command.Group>
</>
)}
{/* ── Wall Mode sub-page ────────────────────────────────────── */}
{page === "wall-mode" && (
<Command.Group heading="Wall Mode">
{(["cutaway", "up", "down"] as const).map((mode) => (
<OptionItem
key={mode}
label={wallModeLabel[mode]}
isActive={wallMode === mode}
onSelect={() => run(() => setWallMode(mode))}
/>
))}
</Command.Group>
)}
{/* ── Level Mode sub-page ───────────────────────────────────── */}
{page === "level-mode" && (
<Command.Group heading="Level Mode">
{(["stacked", "exploded", "solo"] as const).map((mode) => (
<OptionItem
key={mode}
label={levelModeLabel[mode]}
isActive={levelMode === mode}
onSelect={() => run(() => setLevelMode(mode))}
/>
))}
</Command.Group>
)}
{/* ── Go to Level sub-page ──────────────────────────────────── */}
{page === "goto-level" && (
<Command.Group heading="Go to Level">
{allLevels.map((level) => (
<OptionItem
key={level.id}
label={level.name ?? `Level ${level.level}`}
isActive={level.id === activeLevelId}
onSelect={() =>
run(() => useViewer.getState().setSelection({ levelId: level.id }))
}
/>
))}
</Command.Group>
)}
{/* ── Rename Level sub-page ─────────────────────────────────── */}
{page === "rename-level" && (
<Command.Group heading="Rename Level">
<Command.Item
value="confirm-rename"
onSelect={confirmRename}
disabled={!inputValue.trim()}
className="flex cursor-pointer items-center gap-2.5 rounded-md px-2.5 py-2 text-sm text-foreground transition-colors data-[selected=true]:bg-accent data-[disabled=true]:cursor-not-allowed data-[disabled=true]:opacity-40"
>
<span className="flex h-4 w-4 shrink-0 items-center justify-center text-muted-foreground">
<PencilLine className="h-4 w-4" />
</span>
<span className="flex-1 truncate">
{inputValue.trim() ? (
<>Rename to <span className="font-medium">"{inputValue.trim()}"</span></>
) : (
<span className="text-muted-foreground">Type a new name above</span>
)}
</span>
</Command.Item>
</Command.Group>
)}
{/* ── Camera Snapshot: scope picker ─────────────────────────── */}
{page === "camera-view" && (
<Command.Group heading="Camera Snapshot — Select Scope">
<OptionItem
label="Site"
icon={<Map className="h-4 w-4" />}
onSelect={() => {
const { rootNodeIds } = useScene.getState();
const siteId = rootNodeIds[0];
if (siteId) navigateToCameraScope(siteId, "Site");
}}
/>
<OptionItem
label="Building"
icon={<Building2 className="h-4 w-4" />}
onSelect={() => {
const building = Object.values(useScene.getState().nodes).find(
(n) => n.type === "building"
);
if (building) navigateToCameraScope(building.id, "Building");
}}
/>
<OptionItem
label="Level"
icon={<Layers className="h-4 w-4" />}
disabled={!activeLevelId}
onSelect={() => {
if (activeLevelId) navigateToCameraScope(activeLevelId, "Level");
}}
/>
<OptionItem
label="Selection"
icon={<MousePointer2 className="h-4 w-4" />}
disabled={!hasSelection}
onSelect={() => {
const firstId = selection.selectedIds[0];
if (firstId) navigateToCameraScope(firstId, "Selection");
}}
/>
</Command.Group>
)}
{/* ── Camera Snapshot: actions for selected scope ───────────── */}
{page === "camera-scope" && cameraScope && (
<Command.Group heading={`${cameraScope.label} Snapshot`}>
<OptionItem
label={hasScopeSnapshot ? "Update Snapshot" : "Take Snapshot"}
icon={<Camera className="h-4 w-4" />}
onSelect={takeSnapshot}
/>
{hasScopeSnapshot && (
<OptionItem
label="View Snapshot"
icon={<Eye className="h-4 w-4" />}
onSelect={viewSnapshot}
/>
)}
{hasScopeSnapshot && (
<OptionItem
label="Clear Snapshot"
icon={<Trash2 className="h-4 w-4" />}
onSelect={clearSnapshot}
/>
)}
</Command.Group>
)}
</Command.List>
{/* Footer hint */}
<div className="flex items-center justify-between border-t border-border/50 px-3 py-2">
<span className="text-[11px] text-muted-foreground">
<Shortcut keys={["↑", "↓"]} /> navigate
</span>
<span className="text-[11px] text-muted-foreground">
<Shortcut keys={["↵"]} /> select
</span>
{page ? (
<span className="text-[11px] text-muted-foreground">
<Shortcut keys={["⌫"]} /> back
</span>
) : (
<span className="text-[11px] text-muted-foreground">
<Shortcut keys={["Esc"]} /> close
</span>
)}
</div>
</Command>
</DialogContent>
</Dialog>
);
}
@@ -1,31 +0,0 @@
'use client'
import { cn } from '@/lib/utils'
interface ActionButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
icon?: React.ReactNode
label: string
}
export function ActionButton({ icon, label, className, ...props }: ActionButtonProps) {
return (
<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
)}
>
{icon}
<span>{label}</span>
</button>
)
}
export function ActionGroup({ children, className }: { children: React.ReactNode; className?: string }) {
return (
<div className={cn("flex gap-1.5", className)}>
{children}
</div>
)
}
@@ -1,246 +0,0 @@
'use client'
import { useScene } from '@pascal-app/core'
import { useCallback, useEffect, useRef, useState } from 'react'
import { cn } from '@/lib/utils'
interface MetricControlProps {
label: React.ReactNode
value: number
onChange: (value: number) => void
min?: number
max?: number
precision?: number
step?: number
className?: string
unit?: string
}
export function MetricControl({
label,
value,
onChange,
min = -Infinity,
max = Infinity,
precision = 2,
step = 1,
className,
unit = '',
}: MetricControlProps) {
const [isEditing, setIsEditing] = useState(false)
const [isDragging, setIsDragging] = useState(false)
const [isHovered, setIsHovered] = useState(false)
const [inputValue, setInputValue] = useState(value.toFixed(precision))
const startXRef = useRef(0)
const startValueRef = useRef(0)
const containerRef = useRef<HTMLDivElement>(null)
const valueRef = useRef(value)
valueRef.current = value
const clamp = useCallback(
(val: number) => {
return Math.min(Math.max(val, min), max)
},
[min, max],
)
useEffect(() => {
if (!isEditing) {
setInputValue(value.toFixed(precision))
}
}, [value, precision, isEditing])
useEffect(() => {
const container = containerRef.current
if (!container) return
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
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)
}
}
container.addEventListener('wheel', handleWheel, { passive: false })
return () => container.removeEventListener('wheel', handleWheel)
}, [isEditing, step, clamp, onChange, precision])
useEffect(() => {
if (!isHovered || isEditing) return
const handleKeyDown = (e: KeyboardEvent) => {
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)
}
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [isHovered, isEditing, step, clamp, onChange, precision])
const handlePointerDown = useCallback(
(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
const deltaValue = deltaX * dragStep
const newValue = clamp(startValueRef.current + deltaValue)
const newFinalValue = Number.parseFloat(newValue.toFixed(precision))
if (newFinalValue !== finalValue) {
finalValue = newFinalValue
onChange(finalValue)
}
}
const handlePointerUp = () => {
setIsDragging(false)
document.removeEventListener('pointermove', handlePointerMove)
document.removeEventListener('pointerup', handlePointerUp)
if (finalValue !== startValueRef.current) {
onChange(startValueRef.current)
useScene.temporal.getState().resume()
onChange(finalValue)
} else {
useScene.temporal.getState().resume()
}
}
document.addEventListener('pointermove', handlePointerMove)
document.addEventListener('pointerup', handlePointerUp)
},
[isEditing, value, onChange, clamp, precision, step]
)
const handleValueClick = useCallback(() => {
setIsEditing(true)
setInputValue(value.toFixed(precision))
}, [value, precision])
const handleInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setInputValue(e.target.value)
}, [])
const submitValue = useCallback(() => {
const numValue = Number.parseFloat(inputValue)
if (!Number.isNaN(numValue)) {
onChange(clamp(Number.parseFloat(numValue.toFixed(precision))))
} else {
setInputValue(value.toFixed(precision))
}
setIsEditing(false)
}, [inputValue, onChange, clamp, precision, value])
const handleInputBlur = useCallback(() => {
submitValue()
}, [submitValue])
const handleInputKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
submitValue()
} else if (e.key === 'Escape') {
setInputValue(value.toFixed(precision))
setIsEditing(false)
} else if (e.key === 'ArrowUp') {
e.preventDefault()
const newV = clamp(value + step)
onChange(newV)
setInputValue(newV.toFixed(precision))
} else if (e.key === 'ArrowDown') {
e.preventDefault()
const newV = clamp(value - step)
onChange(newV)
setInputValue(newV.toFixed(precision))
}
},
[submitValue, value, precision, step, clamp, onChange],
)
return (
<div
ref={containerRef}
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)}
>
<div
className={cn(
"text-muted-foreground select-none truncate transition-colors",
isDragging ? "cursor-ew-resize text-foreground" : "hover:text-foreground hover:cursor-ew-resize"
)}
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"
onBlur={handleInputBlur}
onChange={handleInputChange}
onKeyDown={handleInputKeyDown}
type="text"
value={inputValue}
/>
{unit && <span className="ml-[1px] text-muted-foreground">{unit}</span>}
</div>
) : (
<div
className="flex w-full cursor-text items-center justify-end text-foreground hover:text-primary transition-colors"
onClick={handleValueClick}
>
<span className="font-mono tabular-nums tracking-tight">
{Number(value.toFixed(precision)).toFixed(precision)}
</span>
{unit && <span className="ml-[1px] text-muted-foreground">{unit}</span>}
</div>
)}
</div>
</div>
)
}
@@ -1,67 +0,0 @@
'use client'
import { cn } from '@/lib/utils'
import { ChevronDown } from 'lucide-react'
import { useState } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
interface PanelSectionProps {
title: string
children: React.ReactNode
defaultExpanded?: boolean
className?: string
}
export function PanelSection({
title,
children,
defaultExpanded = true,
className,
}: PanelSectionProps) {
const [isExpanded, setIsExpanded] = useState(defaultExpanded)
return (
<motion.div
layout
transition={{ type: "spring", bounce: 0, duration: 0.4 }}
className={cn("flex flex-col shrink-0 overflow-hidden border-b border-border/50", className)}
>
<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",
isExpanded
? "bg-accent/50 text-foreground"
: "text-muted-foreground hover:bg-accent/30 hover:text-foreground"
)}
>
<span className="font-medium text-sm truncate">{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"
)}
/>
</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 }}
className="overflow-hidden"
>
<div className="flex flex-col gap-1.5 p-3 pt-2">
{children}
</div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
)
}
@@ -1,40 +0,0 @@
'use client'
import { cn } from '@/lib/utils'
interface SegmentedControlProps<T extends string> {
value: T
onChange: (value: T) => void
options: { label: React.ReactNode; value: T }[]
className?: string
}
export function SegmentedControl<T extends string>({
value,
onChange,
options,
className,
}: SegmentedControlProps<T>) {
return (
<div className={cn("flex h-9 w-full items-center rounded-lg border border-border/50 bg-[#2C2C2E] p-[3px]", className)}>
{options.map((option) => {
const isSelected = value === option.value
return (
<button
key={option.value}
type="button"
onClick={() => onChange(option.value)}
className={cn(
"relative flex h-full flex-1 items-center justify-center rounded-md text-xs font-medium transition-all duration-200",
isSelected
? "bg-[#3e3e3e] text-foreground shadow-sm ring-1 ring-border/50"
: "text-muted-foreground hover:bg-white/5 hover:text-foreground"
)}
>
<span className="relative z-10 flex items-center gap-1.5">{option.label}</span>
</button>
)
})}
</div>
)
}
@@ -1,319 +0,0 @@
'use client'
import { useScene } from '@pascal-app/core'
import { useCallback, useEffect, useRef, useState } from 'react'
import { cn } from '@/lib/utils'
interface SliderControlProps {
label: React.ReactNode
value: number
onChange: (value: number) => void
min?: number
max?: number
precision?: number
step?: number
className?: string
unit?: string
}
export function SliderControl({
label,
value,
onChange,
min = 0,
max = 100,
precision = 0,
step = 1,
className,
unit = '',
}: SliderControlProps) {
const [isEditing, setIsEditing] = useState(false)
const [isDragging, setIsDragging] = useState(false)
const [isHovered, setIsHovered] = useState(false)
const [inputValue, setInputValue] = useState(value.toFixed(precision))
// Track the original value and bounds when dragging starts
const [dragStartValue, setDragStartValue] = useState<number | null>(null)
const [dragMin, setDragMin] = useState<number | null>(null)
const [dragMax, setDragMax] = useState<number | null>(null)
const trackRef = useRef<HTMLDivElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
const valueRef = useRef(value)
valueRef.current = value
const clamp = useCallback(
(val: number) => {
return Math.min(Math.max(val, min), max)
},
[min, max],
)
useEffect(() => {
if (!isEditing) {
setInputValue(value.toFixed(precision))
}
}, [value, precision, isEditing])
useEffect(() => {
const container = containerRef.current
if (!container) return
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
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)
}
}
container.addEventListener('wheel', handleWheel, { passive: false })
return () => container.removeEventListener('wheel', handleWheel)
}, [isEditing, step, clamp, onChange, precision])
useEffect(() => {
if (!isHovered || isEditing) return
const handleKeyDown = (e: KeyboardEvent) => {
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)
}
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [isHovered, isEditing, step, clamp, onChange, precision])
const handlePointerDown = useCallback(
(e: React.PointerEvent) => {
if (isEditing) return
e.preventDefault()
const track = trackRef.current
if (!track) return
setIsDragging(true)
setDragStartValue(value)
setDragMin(min)
setDragMax(max)
useScene.temporal.getState().pause()
const rect = track.getBoundingClientRect()
const updateValueFromEvent = (clientX: number) => {
const percent = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width))
const rawValue = min + percent * (max - min)
// snap to step
const snapped = Math.round(rawValue / step) * step
const finalValue = Number.parseFloat(clamp(snapped).toFixed(precision))
onChange(finalValue)
}
updateValueFromEvent(e.clientX)
const handlePointerMove = (moveEvent: PointerEvent) => {
updateValueFromEvent(moveEvent.clientX)
}
const handlePointerUp = (e: PointerEvent) => {
// Only stop dragging if we didn't release on the reset button
// Let the reset button's onPointerDown handle its own cleanup
if ((e.target as HTMLElement).closest('button')) {
return
}
setIsDragging(false)
const startVal = dragStartValue
const finalVal = valueRef.current
setDragStartValue(null)
setDragMin(null)
setDragMax(null)
document.removeEventListener('pointermove', handlePointerMove)
document.removeEventListener('pointerup', handlePointerUp)
if (startVal !== null && startVal !== finalVal) {
// Revert to start value while paused so the undo baseline is clean
onChange(startVal)
useScene.temporal.getState().resume()
// Apply final value while recording
onChange(finalVal)
} else {
useScene.temporal.getState().resume()
}
}
document.addEventListener('pointermove', handlePointerMove)
document.addEventListener('pointerup', handlePointerUp)
},
[isEditing, min, max, step, precision, clamp, onChange]
)
const handleValueClick = useCallback(() => {
setIsEditing(true)
setInputValue(value.toFixed(precision))
}, [value, precision])
const handleInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setInputValue(e.target.value)
}, [])
const submitValue = useCallback(() => {
const numValue = Number.parseFloat(inputValue)
if (!Number.isNaN(numValue)) {
onChange(clamp(Number.parseFloat(numValue.toFixed(precision))))
} else {
setInputValue(value.toFixed(precision))
}
setIsEditing(false)
}, [inputValue, onChange, clamp, precision, value])
const handleInputBlur = useCallback(() => {
submitValue()
}, [submitValue])
const handleInputKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
submitValue()
} else if (e.key === 'Escape') {
setInputValue(value.toFixed(precision))
setIsEditing(false)
} else if (e.key === 'ArrowUp') {
e.preventDefault()
const newV = clamp(value + step)
onChange(newV)
setInputValue(newV.toFixed(precision))
} else if (e.key === 'ArrowDown') {
e.preventDefault()
const newV = clamp(value - step)
onChange(newV)
setInputValue(newV.toFixed(precision))
}
},
[submitValue, value, precision, step, clamp, onChange],
)
const currentMin = isDragging && dragMin !== null ? dragMin : min
const currentMax = isDragging && dragMax !== null ? dragMax : max
const percent = Math.max(0, Math.min(100, ((value - currentMin) / (currentMax - currentMin)) * 100))
const startPercent = dragStartValue !== null ? Math.max(0, Math.min(100, ((dragStartValue - currentMin) / (currentMax - currentMin)) * 100)) : null
return (
<div
ref={containerRef}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
className={cn("group flex h-12 w-full items-center rounded-lg border border-border/50 px-3 text-sm transition-colors relative", isDragging ? "bg-[#3e3e3e]" : "bg-[#2C2C2E] hover:bg-[#3e3e3e]", className)}
>
{/* Reset button that appears when dragged away from start */}
{isDragging && dragStartValue !== null && dragStartValue !== value && (
<button
className="absolute -top-10 right-0 rounded-md bg-[#2C2C2E] px-2 py-1 text-[10px] font-medium text-muted-foreground shadow-sm ring-1 ring-border/50 hover:bg-[#3e3e3e] hover:text-foreground z-50 pointer-events-auto cursor-pointer"
onPointerDown={(e) => {
e.stopPropagation()
onChange(dragStartValue)
setDragStartValue(null)
setDragMin(null)
setDragMax(null)
setIsDragging(false)
useScene.temporal.getState().resume()
}}
>
Reset
</button>
)}
<div className="w-[80px] shrink-0 text-muted-foreground select-none truncate">
{label}
</div>
<div
ref={trackRef}
className={cn(
"relative flex h-full flex-1 items-center justify-center touch-none mx-2",
isDragging ? "cursor-grabbing" : "cursor-grab"
)}
onPointerDown={handlePointerDown}
>
{/* Track dots background */}
<div className="absolute inset-x-0 flex items-center justify-between opacity-30 px-1 pointer-events-none">
{[...Array(9)].map((_, i) => (
<div key={i} className="h-[3px] w-[3px] rounded-full bg-current" />
))}
</div>
{/* Original Thumb Ghost */}
{isDragging && startPercent !== null && (
<div
className="absolute top-1/2 h-6 w-[3px] -translate-x-1/2 -translate-y-1/2 rounded-full shadow-sm bg-foreground/20 pointer-events-none"
style={{ left: `${startPercent}%` }}
/>
)}
{/* Active Thumb */}
<div
className={cn(
"absolute top-1/2 h-6 w-[3px] -translate-x-1/2 -translate-y-1/2 rounded-full shadow-sm transition pointer-events-none",
isDragging ? "bg-foreground scale-y-110" : "bg-foreground/60 group-hover:bg-foreground/80"
)}
style={{ left: `${percent}%` }}
/>
</div>
<div className="flex w-[50px] 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"
onBlur={handleInputBlur}
onChange={handleInputChange}
onKeyDown={handleInputKeyDown}
type="text"
value={inputValue}
/>
{unit && <span className="ml-[1px] text-muted-foreground">{unit}</span>}
</div>
) : (
<div
className="flex w-full cursor-text items-center justify-end text-foreground/60 hover:text-foreground transition-colors"
onClick={handleValueClick}
>
<span className="font-mono tabular-nums tracking-tight">
{Number(value.toFixed(precision)).toFixed(precision)}
</span>
{unit && <span className="ml-[1px] text-muted-foreground">{unit}</span>}
</div>
)}
</div>
</div>
)
}
@@ -1,40 +0,0 @@
'use client'
import { cn } from '@/lib/utils'
import { Check } from 'lucide-react'
interface ToggleControlProps {
label: string
checked: boolean
onChange: (checked: boolean) => void
className?: string
}
export function ToggleControl({
label,
checked,
onChange,
className,
}: ToggleControlProps) {
return (
<div
className={cn("group flex h-10 w-full cursor-pointer items-center justify-between rounded-lg border border-border/50 bg-[#2C2C2E] px-3 text-sm transition-colors hover:bg-[#3e3e3e]", className)}
onClick={() => onChange(!checked)}
>
<div className="text-muted-foreground transition-colors group-hover:text-foreground select-none">
{label}
</div>
<div
className={cn(
"flex h-5 w-5 items-center justify-center rounded-[4px] border transition-all duration-200",
checked
? "border-primary bg-primary text-primary-foreground"
: "border-border bg-black/20 text-transparent group-hover:border-muted-foreground"
)}
>
<Check className="h-3.5 w-3.5" strokeWidth={3} />
</div>
</div>
)
}
@@ -1,14 +0,0 @@
export function CeilingHelper() {
return (
<div className="pointer-events-none fixed right-4 top-1/2 -translate-y-1/2 z-40 flex flex-col gap-2 rounded-lg border border-border bg-background/95 px-4 py-3 shadow-lg backdrop-blur-md">
<div className="flex items-center gap-2 text-sm">
<kbd className="rounded bg-muted px-2 py-1 text-xs font-medium">Shift</kbd>
<span className="text-muted-foreground">Allow non-45° angles</span>
</div>
<div className="flex items-center gap-2 text-sm">
<kbd className="rounded bg-muted px-2 py-1 text-xs font-medium">Esc</kbd>
<span className="text-muted-foreground">Cancel</span>
</div>
</div>
)
}
@@ -1,33 +0,0 @@
'use client'
import useEditor from '@/store/use-editor'
import { CeilingHelper } from './ceiling-helper'
import { ItemHelper } from './item-helper'
import { RoofHelper } from './roof-helper'
import { SlabHelper } from './slab-helper'
import { WallHelper } from './wall-helper'
export function HelperManager() {
const tool = useEditor((s) => s.tool)
const movingNode = useEditor((state) => state.movingNode)
if (movingNode) {
return <ItemHelper showEsc />
}
// Show appropriate helper based on current tool
switch (tool) {
case 'wall':
return <WallHelper />
case 'item':
return <ItemHelper />
case 'slab':
return <SlabHelper />
case 'ceiling':
return <CeilingHelper />
case 'roof':
return <RoofHelper />
default:
return null
}
}
@@ -1,28 +0,0 @@
interface ItemHelperProps {
showEsc?: boolean
}
export function ItemHelper({ showEsc }: ItemHelperProps) {
return (
<div className="pointer-events-none fixed right-4 top-1/2 -translate-y-1/2 z-40 flex flex-col gap-2 rounded-lg border border-border bg-background/95 px-4 py-3 shadow-lg backdrop-blur-md">
<div className="flex items-center gap-2 text-sm">
<kbd className="rounded bg-muted px-2 py-1 text-xs font-medium">R</kbd>
<span className="text-muted-foreground">Rotate counterclockwise</span>
</div>
<div className="flex items-center gap-2 text-sm">
<kbd className="rounded bg-muted px-2 py-1 text-xs font-medium">T</kbd>
<span className="text-muted-foreground">Rotate clockwise</span>
</div>
<div className="flex items-center gap-2 text-sm">
<kbd className="rounded bg-muted px-2 py-1 text-xs font-medium">Shift</kbd>
<span className="text-muted-foreground">Free place</span>
</div>
{showEsc && (
<div className="flex items-center gap-2 text-sm">
<kbd className="rounded bg-muted px-2 py-1 text-xs font-medium">Esc</kbd>
<span className="text-muted-foreground">Cancel</span>
</div>
)}
</div>
)
}
@@ -1,10 +0,0 @@
export function RoofHelper() {
return (
<div className="pointer-events-none fixed right-4 top-1/2 -translate-y-1/2 z-40 flex flex-col gap-2 rounded-lg border border-border bg-background/95 px-4 py-3 shadow-lg backdrop-blur-md">
<div className="flex items-center gap-2 text-sm">
<kbd className="rounded bg-muted px-2 py-1 text-xs font-medium">Esc</kbd>
<span className="text-muted-foreground">Cancel</span>
</div>
</div>
)
}
@@ -1,14 +0,0 @@
export function SlabHelper() {
return (
<div className="pointer-events-none fixed right-4 top-1/2 -translate-y-1/2 z-40 flex flex-col gap-2 rounded-lg border border-border bg-background/95 px-4 py-3 shadow-lg backdrop-blur-md">
<div className="flex items-center gap-2 text-sm">
<kbd className="rounded bg-muted px-2 py-1 text-xs font-medium">Shift</kbd>
<span className="text-muted-foreground">Allow non-45° angles</span>
</div>
<div className="flex items-center gap-2 text-sm">
<kbd className="rounded bg-muted px-2 py-1 text-xs font-medium">Esc</kbd>
<span className="text-muted-foreground">Cancel</span>
</div>
</div>
)
}
@@ -1,14 +0,0 @@
export function WallHelper() {
return (
<div className="pointer-events-none fixed right-4 top-1/2 -translate-y-1/2 z-40 flex flex-col gap-2 rounded-lg border border-border bg-background/95 px-4 py-3 shadow-lg backdrop-blur-md">
<div className="flex items-center gap-2 text-sm">
<kbd className="rounded bg-muted px-2 py-1 text-xs font-medium">Shift</kbd>
<span className="text-muted-foreground">Allow non-45° angles</span>
</div>
<div className="flex items-center gap-2 text-sm">
<kbd className="rounded bg-muted px-2 py-1 text-xs font-medium">Esc</kbd>
<span className="text-muted-foreground">Cancel</span>
</div>
</div>
)
}
File diff suppressed because it is too large Load Diff
@@ -1,213 +0,0 @@
"use client";
import { AssetInput } from "@pascal-app/core";
import { resolveCdnUrl } from "@pascal-app/viewer";
import Image from "next/image";
import { useEffect, useState } from "react";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/primitives/tooltip";
import { cn } from "@/lib/utils";
import useEditor, { CatalogCategory } from "@/store/use-editor";
import { CATALOG_ITEMS } from "./catalog-items";
const PLACEMENT_TAGS = new Set(["floor", "wall", "ceiling", "countertop"]);
export function ItemCatalog({ category }: { category: CatalogCategory }) {
const selectedItem = useEditor((state) => state.selectedItem);
const setSelectedItem = useEditor((state) => state.setSelectedItem);
const [activePlacementTag, setActivePlacementTag] = useState<string | null>(null);
const [activeFunctionalTag, setActiveFunctionalTag] = useState<string | null>(null);
const categoryItems = CATALOG_ITEMS.filter(
(item) => item.category === category,
);
// Collect tags available in this category
const allTags = Array.from(
new Set(categoryItems.flatMap((item) => item.tags ?? [])),
);
const placementTags = allTags.filter((t) => PLACEMENT_TAGS.has(t));
const functionalTags = allTags.filter((t) => !PLACEMENT_TAGS.has(t));
const hasFilters = allTags.length > 1;
// Count items for a placement tag given the current functional filter
const placementCount = (tag: string | null) =>
categoryItems.filter((item) => {
const tags = item.tags ?? [];
if (tag !== null && !tags.includes(tag)) return false;
if (activeFunctionalTag && !tags.includes(activeFunctionalTag)) return false;
return true;
}).length;
// Count items for a functional tag given the current placement filter
const functionalCount = (tag: string) =>
categoryItems.filter((item) => {
const tags = item.tags ?? [];
if (!tags.includes(tag)) return false;
if (activePlacementTag && !tags.includes(activePlacementTag)) return false;
return true;
}).length;
const filteredItems = categoryItems.filter((item) => {
const tags = item.tags ?? [];
if (activePlacementTag && !tags.includes(activePlacementTag)) return false;
if (activeFunctionalTag && !tags.includes(activeFunctionalTag)) return false;
return true;
});
// Auto-select first item if current selection is not in the filtered list
useEffect(() => {
const isCurrentItemInCategory = filteredItems.some(
(item) => item.src === selectedItem?.src,
);
if (!isCurrentItemInCategory && filteredItems.length > 0) {
setSelectedItem(filteredItems[0] as AssetInput);
}
}, [filteredItems, selectedItem?.src, setSelectedItem]);
// Get attachment icon based on attachTo type
const getAttachmentIcon = (attachTo: AssetInput["attachTo"]) => {
if (attachTo === "wall" || attachTo === "wall-side") {
return "/icons/wall.png";
}
if (attachTo === "ceiling") {
return "/icons/ceiling.png";
}
return null;
};
return (
<div className="flex flex-col gap-2">
{/* Filter chips */}
{hasFilters && (
<div className="flex flex-col gap-1.5">
{/* Placement row */}
{placementTags.length > 0 && (
<div className="flex flex-wrap gap-1">
<button
type="button"
onClick={() => setActivePlacementTag(null)}
className={cn(
"cursor-pointer rounded-md px-2 py-0.5 text-xs font-medium transition-colors",
activePlacementTag === null
? "bg-blue-500 text-white"
: "bg-blue-950/50 text-blue-300 hover:bg-blue-900/60 hover:text-blue-200",
)}
>
All
</button>
{placementTags.map((tag) => {
const count = placementCount(tag);
const isActive = activePlacementTag === tag;
const isEmpty = count === 0 && !isActive;
return (
<button
key={tag}
type="button"
disabled={isEmpty}
onClick={() => setActivePlacementTag(isActive ? null : tag)}
className={cn(
"inline-flex cursor-pointer items-center gap-1 rounded-md pl-2 pr-1.5 py-0.5 text-xs font-medium transition-colors capitalize",
isActive
? "bg-blue-500 text-white"
: isEmpty
? "cursor-not-allowed bg-zinc-800 text-zinc-500"
: "bg-blue-950/50 text-blue-300 hover:bg-blue-900/60 hover:text-blue-200",
)}
>
{tag}
<span className={cn("text-[10px]", isActive ? "text-blue-200" : isEmpty ? "text-zinc-600" : "text-blue-500/70")}>
{count}
</span>
</button>
);
})}
</div>
)}
{/* Functional row */}
{functionalTags.length > 0 && (
<div className="flex flex-wrap gap-1">
{functionalTags.map((tag) => {
const count = functionalCount(tag);
const isActive = activeFunctionalTag === tag;
const isEmpty = count === 0 && !isActive;
return (
<button
key={tag}
type="button"
disabled={isEmpty}
onClick={() => setActiveFunctionalTag(isActive ? null : tag)}
className={cn(
"inline-flex cursor-pointer items-center gap-1 rounded-md pl-2 pr-1.5 py-0.5 text-xs font-medium transition-colors capitalize",
isActive
? "bg-violet-500 text-white"
: isEmpty
? "cursor-not-allowed bg-zinc-800 text-zinc-500"
: "bg-muted text-muted-foreground hover:bg-muted/80 hover:text-foreground",
)}
>
{tag}
<span className={cn("text-[10px]", isActive ? "text-violet-200" : isEmpty ? "text-zinc-600" : "text-zinc-500/70")}>
{count}
</span>
</button>
);
})}
</div>
)}
</div>
)}
{/* Items */}
<div className="-mx-2 -my-2 flex max-w-xl gap-2 overflow-x-auto p-2">
{filteredItems.map((item, index) => {
const isSelected = selectedItem?.src === item?.src;
const attachmentIcon = getAttachmentIcon(item?.attachTo);
return (
<Tooltip key={index}>
<TooltipTrigger asChild>
<button
className={cn(
"relative aspect-square min-w-14 min-h-14 h-14 w-14 shrink-0 flex-col gap-px rounded-lg transition-all duration-200 ease-out hover:scale-105 hover:cursor-pointer",
isSelected && "ring-2 ring-primary-foreground",
)}
onClick={() => setSelectedItem(item)}
type="button"
>
<Image
alt={item.name}
className="rounded-lg object-cover"
fill
src={resolveCdnUrl(item.thumbnail) || ""}
/>
{attachmentIcon && (
<div className="absolute right-0.5 bottom-0.5 flex h-4 w-4 items-center justify-center rounded bg-black/60">
<Image
alt={
item.attachTo === "ceiling"
? "Ceiling attachment"
: "Wall attachment"
}
className="h-4 w-4"
height={16}
src={attachmentIcon}
width={16}
/>
</div>
)}
</button>
</TooltipTrigger>
<TooltipContent className="text-xs" side="top">
{item.name}
</TooltipContent>
</Tooltip>
);
})}
</div>
</div>
);
}
@@ -1,218 +0,0 @@
'use client'
import { type AnyNode, type CeilingNode, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { Edit, Plus, Trash2 } from 'lucide-react'
import { useCallback, useEffect } from 'react'
import useEditor from '@/store/use-editor'
import { PanelWrapper } from './panel-wrapper'
import { PanelSection } from '../controls/panel-section'
import { SliderControl } from '../controls/slider-control'
import { ActionButton } from '../controls/action-button'
export function CeilingPanel() {
const selectedIds = useViewer((s) => s.selection.selectedIds)
const setSelection = useViewer((s) => s.setSelection)
const nodes = useScene((s) => s.nodes)
const updateNode = useScene((s) => s.updateNode)
const editingHole = useEditor((s) => s.editingHole)
const setEditingHole = useEditor((s) => s.setEditingHole)
const selectedId = selectedIds[0]
const node = selectedId
? (nodes[selectedId as AnyNode['id']] as CeilingNode | undefined)
: undefined
const handleUpdate = useCallback(
(updates: Partial<CeilingNode>) => {
if (!selectedId) return
updateNode(selectedId as AnyNode['id'], updates)
},
[selectedId, updateNode],
)
const handleClose = useCallback(() => {
setSelection({ selectedIds: [] })
setEditingHole(null)
}, [setSelection, setEditingHole])
useEffect(() => {
if (!node) {
setEditingHole(null)
}
}, [node, setEditingHole])
useEffect(() => {
return () => {
setEditingHole(null)
}
}, [setEditingHole])
const handleAddHole = useCallback(() => {
if (!node || !selectedId) return
const polygon = node.polygon
let cx = 0
let cz = 0
for (const [x, z] of polygon) {
cx += x
cz += z
}
cx /= polygon.length
cz /= polygon.length
const holeSize = 0.5
const newHole: Array<[number, number]> = [
[cx - holeSize, cz - holeSize],
[cx + holeSize, cz - holeSize],
[cx + holeSize, cz + holeSize],
[cx - holeSize, cz + holeSize],
]
const currentHoles = node?.holes || []
handleUpdate({ holes: [...currentHoles, newHole] })
setEditingHole({ nodeId: selectedId, holeIndex: currentHoles.length })
}, [node, selectedId, handleUpdate, setEditingHole])
const handleEditHole = useCallback(
(index: number) => {
if (!selectedId) return
setEditingHole({ nodeId: selectedId, holeIndex: index })
},
[selectedId, setEditingHole],
)
const handleDeleteHole = useCallback(
(index: number) => {
if (!selectedId) return
const currentHoles = node?.holes || []
const newHoles = currentHoles.filter((_, i) => i !== index)
handleUpdate({ holes: newHoles })
if (editingHole?.nodeId === selectedId && editingHole?.holeIndex === index) {
setEditingHole(null)
}
},
[selectedId, node?.holes, handleUpdate, editingHole, setEditingHole],
)
if (!node || node.type !== 'ceiling' || selectedIds.length !== 1) return null
const calculateArea = (polygon: Array<[number, number]>): number => {
if (polygon.length < 3) return 0
let area = 0
const n = polygon.length
for (let i = 0; i < n; i++) {
const j = (i + 1) % n
area += polygon[i]![0] * polygon[j]![1]
area -= polygon[j]![0] * polygon[i]![1]
}
return Math.abs(area) / 2
}
const area = calculateArea(node.polygon)
return (
<PanelWrapper
title={node.name || "Ceiling"}
icon="/icons/ceiling.png"
onClose={handleClose}
width={320}
>
<PanelSection title="Height">
<SliderControl
label="Height"
value={Math.round(node.height * 1000) / 1000}
onChange={(v) => handleUpdate({ height: v })}
min={0}
max={6}
precision={3}
step={0.01}
unit="m"
/>
<div className="mt-2 grid grid-cols-3 gap-1.5 px-1 pb-1">
<ActionButton label="Low (2.4m)" onClick={() => handleUpdate({ height: 2.4 })} />
<ActionButton label="Standard (2.5m)" onClick={() => handleUpdate({ height: 2.5 })} />
<ActionButton label="High (3.0m)" onClick={() => handleUpdate({ height: 3.0 })} />
</div>
</PanelSection>
<PanelSection title="Info">
<div className="flex items-center justify-between px-2 py-1 text-sm text-muted-foreground">
<span>Area</span>
<span className="font-mono text-white">{area.toFixed(2)} m²</span>
</div>
</PanelSection>
<PanelSection title="Holes">
{node.holes && node.holes.length > 0 ? (
<div className="flex flex-col gap-1 pb-2">
{node.holes.map((hole, index) => {
const holeArea = calculateArea(hole)
const isEditing = editingHole?.nodeId === selectedId && editingHole?.holeIndex === index
return (
<div
key={index}
className={`flex items-center justify-between rounded-lg border p-2 transition-colors ${
isEditing
? 'border-primary/50 bg-primary/10'
: 'border-transparent hover:bg-accent/30'
}`}
>
<div className="flex-1 min-w-0">
<p className={`text-xs font-medium ${isEditing ? 'text-primary' : 'text-white'}`}>
Hole {index + 1} {isEditing && '(Editing)'}
</p>
<p className="text-[10px] text-muted-foreground">
{holeArea.toFixed(2)} m² · {hole.length} pts
</p>
</div>
<div className="flex items-center gap-1">
{isEditing ? (
<ActionButton
label="Done"
onClick={() => setEditingHole(null)}
className="h-7 bg-primary text-primary-foreground hover:bg-primary/90"
/>
) : (
<>
<button
type="button"
className="flex h-7 w-7 items-center justify-center rounded-md bg-[#2C2C2E] text-muted-foreground hover:bg-[#3e3e3e] hover:text-foreground"
onClick={() => handleEditHole(index)}
>
<Edit className="h-3.5 w-3.5" />
</button>
<button
type="button"
className="flex h-7 w-7 items-center justify-center rounded-md bg-red-500/10 text-red-400 hover:bg-red-500/20 hover:text-red-300"
onClick={() => handleDeleteHole(index)}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</>
)}
</div>
</div>
)
})}
</div>
) : (
<div className="px-2 py-3 text-center text-xs text-muted-foreground">
No holes
</div>
)}
<div className="px-1 pt-1 pb-1">
<ActionButton
icon={<Plus className="h-3.5 w-3.5" />}
label="Add Hole"
onClick={handleAddHole}
className="w-full"
disabled={editingHole?.nodeId === selectedId}
/>
</div>
</PanelSection>
</PanelWrapper>
)
}
@@ -1,295 +0,0 @@
'use client'
import type { AnyNodeId, Collection, CollectionId } from '@pascal-app/core'
import { useScene } from '@pascal-app/core'
import { Check, ChevronDown, ChevronRight, Layers, MoreHorizontal, Pencil, Plus, Trash2, X } from 'lucide-react'
import { useState } from 'react'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/primitives/dropdown-menu'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/primitives/popover'
import { ColorDot } from '@/components/ui/primitives/color-dot'
import { cn } from '@/lib/utils'
interface CollectionsPopoverProps {
nodeId: AnyNodeId
collectionIds?: CollectionId[]
children: React.ReactNode
}
export function CollectionsPopover({ nodeId, collectionIds, children }: CollectionsPopoverProps) {
const collections = useScene((s) => s.collections)
const nodes = useScene((s) => s.nodes)
const createCollection = useScene((s) => s.createCollection)
const deleteCollection = useScene((s) => s.deleteCollection)
const updateCollection = useScene((s) => s.updateCollection)
const addToCollection = useScene((s) => s.addToCollection)
const removeFromCollection = useScene((s) => s.removeFromCollection)
const [open, setOpen] = useState(false)
const [showCreateInput, setShowCreateInput] = useState(false)
const [createName, setCreateName] = useState('')
const [renamingId, setRenamingId] = useState<CollectionId | null>(null)
const [renameValue, setRenameValue] = useState('')
const [renameColor, setRenameColor] = useState('')
const [deletingId, setDeletingId] = useState<CollectionId | null>(null)
const [expandedIds, setExpandedIds] = useState<Set<CollectionId>>(new Set())
const memberIds = collectionIds ?? []
const allCollections = Object.values(collections)
const handleCreate = () => {
if (!createName.trim()) return
createCollection(createName.trim(), [nodeId])
setCreateName('')
setShowCreateInput(false)
}
const handleRenameConfirm = (id: CollectionId) => {
if (!renameValue.trim()) return
updateCollection(id, { name: renameValue.trim(), color: renameColor || undefined })
setRenamingId(null)
}
const toggleMembership = (collectionId: CollectionId) => {
if (memberIds.includes(collectionId)) {
removeFromCollection(collectionId, nodeId)
} else {
addToCollection(collectionId, nodeId)
}
}
const toggleExpand = (collectionId: CollectionId) => {
setExpandedIds((prev) => {
const next = new Set(prev)
if (next.has(collectionId)) next.delete(collectionId)
else next.add(collectionId)
return next
})
}
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>{children}</PopoverTrigger>
<PopoverContent
side="left"
align="start"
sideOffset={8}
className="w-72 p-0 border-border/50 bg-sidebar/95 backdrop-blur-xl shadow-2xl rounded-xl overflow-hidden"
>
{/* Header */}
<div className="flex items-center justify-between px-3 py-2.5 border-b border-border/50">
<div className="flex items-center gap-1.5">
<Layers className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-xs font-semibold text-foreground tracking-tight">Collections</span>
</div>
<button
type="button"
onClick={() => { setShowCreateInput((v) => !v); setCreateName('') }}
className="flex items-center gap-1 rounded-md px-2 py-1 text-[11px] font-medium text-muted-foreground hover:text-foreground hover:bg-white/10 transition-colors"
>
<Plus className="h-3 w-3" />
New
</button>
</div>
{/* Create input */}
{showCreateInput && (
<div className="flex items-center gap-1.5 px-3 py-2 border-b border-border/50 bg-white/5">
<input
autoFocus
value={createName}
onChange={(e) => setCreateName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleCreate()
if (e.key === 'Escape') { setShowCreateInput(false); setCreateName('') }
}}
placeholder="Collection name…"
className="flex-1 min-w-0 rounded-md border border-border/50 bg-background/50 px-2 py-1 text-xs text-foreground placeholder:text-muted-foreground/60 outline-none focus:border-ring focus:ring-1 focus:ring-ring/30"
/>
<button
type="button"
disabled={!createName.trim()}
onClick={handleCreate}
className="flex h-6 w-6 items-center justify-center rounded-md bg-primary/20 hover:bg-primary/30 text-primary disabled:opacity-40 transition-colors"
>
<Check className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={() => { setShowCreateInput(false); setCreateName('') }}
className="flex h-6 w-6 items-center justify-center rounded-md hover:bg-white/10 text-muted-foreground transition-colors"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
)}
{/* Collections list */}
<div className="max-h-72 overflow-y-auto no-scrollbar">
{allCollections.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-2 py-8 text-center px-4">
<Layers className="h-6 w-6 text-muted-foreground/40" />
<p className="text-xs text-muted-foreground">
No collections yet. Create one to group items together.
</p>
</div>
) : (
<ul className="divide-y divide-border/30">
{allCollections.map((collection) => {
const isIn = memberIds.includes(collection.id)
const isExpanded = expandedIds.has(collection.id)
const isRenaming = renamingId === collection.id
const isDeleting = deletingId === collection.id
if (isDeleting) {
return (
<li key={collection.id} className="flex items-center justify-between gap-2 px-3 py-2.5 bg-red-500/10">
<span className="text-xs text-foreground/80 truncate">Delete "{collection.name}"?</span>
<div className="flex items-center gap-1 shrink-0">
<button
type="button"
onClick={() => { deleteCollection(collection.id); setDeletingId(null) }}
className="rounded-md px-2 py-0.5 text-[11px] font-medium bg-red-500/20 text-red-400 hover:bg-red-500/30 transition-colors"
>
Delete
</button>
<button
type="button"
onClick={() => setDeletingId(null)}
className="rounded-md px-2 py-0.5 text-[11px] font-medium hover:bg-white/10 text-muted-foreground transition-colors"
>
Cancel
</button>
</div>
</li>
)
}
if (isRenaming) {
return (
<li key={collection.id} className="flex items-center gap-1.5 px-3 py-2">
<ColorDot color={renameColor || '#6366f1'} onChange={setRenameColor} />
<input
autoFocus
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleRenameConfirm(collection.id)
if (e.key === 'Escape') setRenamingId(null)
}}
className="flex-1 min-w-0 rounded-md border border-border/50 bg-background/50 px-2 py-1 text-xs text-foreground outline-none focus:border-ring focus:ring-1 focus:ring-ring/30"
/>
<button
type="button"
onClick={() => handleRenameConfirm(collection.id)}
className="flex h-6 w-6 items-center justify-center rounded-md bg-primary/20 hover:bg-primary/30 text-primary transition-colors"
>
<Check className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={() => setRenamingId(null)}
className="flex h-6 w-6 items-center justify-center rounded-md hover:bg-white/10 text-muted-foreground transition-colors"
>
<X className="h-3.5 w-3.5" />
</button>
</li>
)
}
return (
<li key={collection.id}>
<div className="group flex items-center gap-2 px-3 py-2 hover:bg-white/5 transition-colors">
{/* Color dot — click to pick color */}
<ColorDot
color={collection.color ?? '#6366f1'}
onChange={(c) => updateCollection(collection.id, { color: c })}
/>
{/* Name + count — clicking toggles membership */}
<button
type="button"
onClick={() => toggleMembership(collection.id)}
className="flex-1 min-w-0 flex items-center gap-1.5 text-left"
>
<span className={cn('truncate text-xs font-medium', isIn ? 'text-foreground' : 'text-muted-foreground')}>
{collection.name}
</span>
<span className="shrink-0 text-[10px] text-muted-foreground/60">
{collection.nodeIds.length}
</span>
</button>
{/* Membership check */}
<div
className={cn(
'flex h-4 w-4 shrink-0 items-center justify-center rounded border transition-colors pointer-events-none',
isIn ? 'border-primary bg-primary/20 text-primary' : 'border-border/50',
)}
>
{isIn && <Check className="h-2.5 w-2.5" />}
</div>
{/* Expand toggle (only if has members) */}
{collection.nodeIds.length > 0 && (
<button
type="button"
onClick={() => toggleExpand(collection.id)}
className="flex h-5 w-5 shrink-0 items-center justify-center rounded text-muted-foreground hover:text-foreground transition-colors"
>
{isExpanded
? <ChevronDown className="h-3 w-3" />
: <ChevronRight className="h-3 w-3" />}
</button>
)}
{/* More dropdown */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="flex h-5 w-5 shrink-0 items-center justify-center rounded text-muted-foreground hover:text-foreground hover:bg-white/10 transition-colors opacity-0 group-hover:opacity-100"
>
<MoreHorizontal className="h-3.5 w-3.5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent side="left" align="start" className="min-w-40">
<DropdownMenuItem onClick={() => { setRenamingId(collection.id); setRenameValue(collection.name); setRenameColor(collection.color ?? '') }}>
<Pencil className="h-3.5 w-3.5" />
Rename
</DropdownMenuItem>
<DropdownMenuItem variant="destructive" onClick={() => setDeletingId(collection.id)}>
<Trash2 className="h-3.5 w-3.5" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* Expanded member list */}
{isExpanded && (
<ul className="pb-1 pl-6 pr-3 flex flex-col gap-0.5">
{collection.nodeIds.map((nid) => {
const n = nodes[nid]
return (
<li key={nid} className="flex items-center gap-1.5 py-0.5">
<span className="h-1 w-1 rounded-full bg-muted-foreground/40 shrink-0" />
<span className={cn('truncate text-[11px]', nid === nodeId ? 'text-foreground font-medium' : 'text-muted-foreground')}>
{n?.name ?? nid}
</span>
</li>
)
})}
</ul>
)}
</li>
)
})}
</ul>
)}
</div>
</PopoverContent>
</Popover>
)
}
@@ -1,550 +0,0 @@
'use client'
import { type AnyNode, type AnyNodeId, DoorNode, emitter, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { BookMarked, Copy, FlipHorizontal2, Move, Trash2 } from 'lucide-react'
import { useCallback } from 'react'
import { sfxEmitter } from '@/lib/sfx-bus'
import useEditor from '@/store/use-editor'
import { PanelWrapper } from './panel-wrapper'
import { PanelSection } from '../controls/panel-section'
import { SliderControl } from '../controls/slider-control'
import { MetricControl } from '../controls/metric-control'
import { ToggleControl } from '../controls/toggle-control'
import { SegmentedControl } from '../controls/segmented-control'
import { ActionButton, ActionGroup } from '../controls/action-button'
import { PresetsPopover } from './presets/presets-popover'
export function DoorPanel() {
const selectedIds = useViewer((s) => s.selection.selectedIds)
const setSelection = useViewer((s) => s.setSelection)
const nodes = useScene((s) => s.nodes)
const updateNode = useScene((s) => s.updateNode)
const deleteNode = useScene((s) => s.deleteNode)
const setMovingNode = useEditor((s) => s.setMovingNode)
const selectedId = selectedIds[0]
const node = selectedId
? (nodes[selectedId as AnyNode['id']] as DoorNode | undefined)
: undefined
const handleUpdate = useCallback(
(updates: Partial<DoorNode>) => {
if (!selectedId) return
updateNode(selectedId as AnyNode['id'], updates)
useScene.getState().dirtyNodes.add(selectedId as AnyNodeId)
},
[selectedId, updateNode],
)
const handleClose = useCallback(() => {
setSelection({ selectedIds: [] })
}, [setSelection])
const handleFlip = useCallback(() => {
if (!node) return
handleUpdate({
side: node.side === 'front' ? 'back' : 'front',
rotation: [node.rotation[0], node.rotation[1] + Math.PI, node.rotation[2]],
})
}, [node, handleUpdate])
const handleMove = useCallback(() => {
if (!node) return
sfxEmitter.emit('sfx:item-pick')
setMovingNode(node)
setSelection({ selectedIds: [] })
}, [node, setMovingNode, setSelection])
const handleDelete = useCallback(() => {
if (!selectedId || !node) return
sfxEmitter.emit('sfx:item-delete')
deleteNode(selectedId as AnyNode['id'])
if (node.parentId) useScene.getState().dirtyNodes.add(node.parentId as AnyNodeId)
setSelection({ selectedIds: [] })
}, [selectedId, node, deleteNode, setSelection])
const handleDuplicate = useCallback(() => {
if (!node || !node.parentId) return
sfxEmitter.emit('sfx:item-pick')
useScene.temporal.getState().pause()
const cloned = structuredClone(node) as any
delete cloned.id
cloned.metadata = { ...cloned.metadata, isNew: true }
const duplicate = DoorNode.parse(cloned)
useScene.getState().createNode(duplicate, node.parentId as AnyNodeId)
setMovingNode(duplicate)
setSelection({ selectedIds: [] })
}, [node, setMovingNode, setSelection])
const setSegmentHeightRatio = (segIdx: number, newVal: number) => {
const numSegs = node!.segments.length
const totalH = node!.segments.reduce((sum, s) => sum + s.heightRatio, 0)
const normH = node!.segments.map(s => s.heightRatio / totalH)
const clamped = Math.max(0.05, Math.min(0.95, newVal))
const neighborIdx = segIdx < numSegs - 1 ? segIdx + 1 : segIdx - 1
const delta = clamped - normH[segIdx]!
const neighborVal = Math.max(0.05, normH[neighborIdx]! - delta)
const newRatios = normH.map((v, i) => {
if (i === segIdx) return clamped
if (i === neighborIdx) return neighborVal
return v
})
const updated = node!.segments.map((s, idx) => ({ ...s, heightRatio: newRatios[idx]! }))
handleUpdate({ segments: updated })
}
const setSegmentColumnRatio = (segIdx: number, colIdx: number, newVal: number) => {
const seg = node!.segments[segIdx]!
const normRatios = (() => {
const sum = seg.columnRatios.reduce((a, b) => a + b, 0)
return seg.columnRatios.map(r => r / sum)
})()
const numCols = normRatios.length
const clamped = Math.max(0.05, Math.min(0.95, newVal))
const neighborIdx = colIdx < numCols - 1 ? colIdx + 1 : colIdx - 1
const delta = clamped - normRatios[colIdx]!
const neighborVal = Math.max(0.05, normRatios[neighborIdx]! - delta)
const newRatios = normRatios.map((v, i) => {
if (i === colIdx) return clamped
if (i === neighborIdx) return neighborVal
return v
})
const updated = node!.segments.map((s, idx) =>
idx === segIdx ? { ...s, columnRatios: newRatios } : s,
)
handleUpdate({ segments: updated })
}
const getDoorPresetData = useCallback(() => {
if (!node) return null
return {
width: node.width,
height: node.height,
frameThickness: node.frameThickness,
frameDepth: node.frameDepth,
contentPadding: node.contentPadding,
hingesSide: node.hingesSide,
swingDirection: node.swingDirection,
threshold: node.threshold,
thresholdHeight: node.thresholdHeight,
handle: node.handle,
handleHeight: node.handleHeight,
handleSide: node.handleSide,
doorCloser: node.doorCloser,
panicBar: node.panicBar,
panicBarHeight: node.panicBarHeight,
segments: node.segments,
}
}, [node])
const handleSavePreset = useCallback(async (name: string) => {
const data = getDoorPresetData()
if (!data || !selectedId) return
const res = await fetch('/api/presets', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'door', name, data }),
})
if (res.ok) {
const json = await res.json()
const presetId = json.preset?.id
if (presetId) emitter.emit('preset:generate-thumbnail', { presetId, nodeId: selectedId })
}
}, [getDoorPresetData, selectedId])
const handleOverwritePreset = useCallback(async (id: string) => {
const data = getDoorPresetData()
if (!data || !selectedId) return
const res = await fetch(`/api/presets/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ data }),
})
if (res.ok) emitter.emit('preset:generate-thumbnail', { presetId: id, nodeId: selectedId })
}, [getDoorPresetData, selectedId])
const handleApplyPreset = useCallback((data: Record<string, unknown>) => {
handleUpdate(data as Partial<DoorNode>)
}, [handleUpdate])
if (!node || node.type !== 'door' || selectedIds.length !== 1) return null
const hSum = node.segments.reduce((s, seg) => s + seg.heightRatio, 0)
const normHeights = node.segments.map(seg => seg.heightRatio / hSum)
return (
<PanelWrapper
title={node.name || "Door"}
icon="/icons/door.png"
onClose={handleClose}
width={320}
>
{/* Presets strip */}
<div className="px-3 pt-2.5 pb-1.5 border-b border-border/30">
<PresetsPopover type="door" onApply={handleApplyPreset} onSave={handleSavePreset} onOverwrite={handleOverwritePreset}>
<button className="flex w-full items-center gap-2 rounded-lg border border-border/50 bg-[#2C2C2E] px-3 py-2 text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-[#3e3e3e] transition-colors">
<BookMarked className="h-3.5 w-3.5 shrink-0" />
<span>Presets</span>
</button>
</PresetsPopover>
</div>
<PanelSection title="Position">
<SliderControl
label={<>X<sub className="text-[11px] ml-[1px] opacity-70">wall</sub></>}
value={Math.round(node.position[0] * 100) / 100}
onChange={(v) => handleUpdate({ position: [v, node.position[1], node.position[2]] })}
min={-10}
max={10}
precision={2}
step={0.1}
unit="m"
/>
<div className="pt-2 pb-1 px-1">
<ActionButton
icon={<FlipHorizontal2 className="h-4 w-4" />}
label="Flip Side"
onClick={handleFlip}
className="w-full"
/>
</div>
</PanelSection>
<PanelSection title="Dimensions">
<SliderControl
label="Width"
value={Math.round(node.width * 100) / 100}
onChange={(v) => handleUpdate({ width: v })}
min={0.5}
max={3}
precision={2}
step={0.05}
unit="m"
/>
<SliderControl
label="Height"
value={Math.round(node.height * 100) / 100}
onChange={(v) => handleUpdate({ height: v, position: [node.position[0], v / 2, node.position[2]] })}
min={1.0}
max={4}
precision={2}
step={0.05}
unit="m"
/>
</PanelSection>
<PanelSection title="Frame">
<SliderControl
label="Thickness"
value={Math.round(node.frameThickness * 1000) / 1000}
onChange={(v) => handleUpdate({ frameThickness: v })}
min={0.01}
max={0.2}
precision={3}
step={0.01}
unit="m"
/>
<SliderControl
label="Depth"
value={Math.round(node.frameDepth * 1000) / 1000}
onChange={(v) => handleUpdate({ frameDepth: v })}
min={0.01}
max={0.3}
precision={3}
step={0.01}
unit="m"
/>
</PanelSection>
<PanelSection title="Content Padding">
<SliderControl
label="Horizontal"
value={Math.round(node.contentPadding[0] * 1000) / 1000}
onChange={(v) => handleUpdate({ contentPadding: [v, node.contentPadding[1]] })}
min={0}
max={0.2}
precision={3}
step={0.005}
unit="m"
/>
<SliderControl
label="Vertical"
value={Math.round(node.contentPadding[1] * 1000) / 1000}
onChange={(v) => handleUpdate({ contentPadding: [node.contentPadding[0], v] })}
min={0}
max={0.2}
precision={3}
step={0.005}
unit="m"
/>
</PanelSection>
<PanelSection title="Swing">
<div className="flex flex-col gap-2 px-1 pb-1">
<div className="space-y-1">
<span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground/80">Hinges Side</span>
<SegmentedControl
value={node.hingesSide}
onChange={(v) => handleUpdate({ hingesSide: v })}
options={[
{ label: 'Left', value: 'left' },
{ label: 'Right', value: 'right' },
]}
/>
</div>
<div className="space-y-1">
<span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground/80">Direction</span>
<SegmentedControl
value={node.swingDirection}
onChange={(v) => handleUpdate({ swingDirection: v })}
options={[
{ label: 'Inward', value: 'inward' },
{ label: 'Outward', value: 'outward' },
]}
/>
</div>
</div>
</PanelSection>
<PanelSection title="Threshold">
<ToggleControl
label="Enable Threshold"
checked={node.threshold}
onChange={(checked) => handleUpdate({ threshold: checked })}
/>
{node.threshold && (
<div className="mt-1 flex flex-col gap-1">
<SliderControl
label="Height"
value={Math.round(node.thresholdHeight * 1000) / 1000}
onChange={(v) => handleUpdate({ thresholdHeight: v })}
min={0.005}
max={0.1}
precision={3}
step={0.005}
unit="m"
/>
</div>
)}
</PanelSection>
<PanelSection title="Handle">
<ToggleControl
label="Enable Handle"
checked={node.handle}
onChange={(checked) => handleUpdate({ handle: checked })}
/>
{node.handle && (
<div className="mt-1 flex flex-col gap-1">
<SliderControl
label="Height"
value={Math.round(node.handleHeight * 100) / 100}
onChange={(v) => handleUpdate({ handleHeight: v })}
min={0.5}
max={node.height - 0.1}
precision={2}
step={0.05}
unit="m"
/>
<div className="space-y-1">
<span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground/80">Handle Side</span>
<SegmentedControl
value={node.handleSide}
onChange={(v) => handleUpdate({ handleSide: v })}
options={[
{ label: 'Left', value: 'left' },
{ label: 'Right', value: 'right' },
]}
/>
</div>
</div>
)}
</PanelSection>
<PanelSection title="Hardware">
<ToggleControl
label="Door Closer"
checked={node.doorCloser}
onChange={(checked) => handleUpdate({ doorCloser: checked })}
/>
<ToggleControl
label="Panic Bar"
checked={node.panicBar}
onChange={(checked) => handleUpdate({ panicBar: checked })}
/>
{node.panicBar && (
<div className="mt-1 flex flex-col gap-1">
<SliderControl
label="Bar Height"
value={Math.round(node.panicBarHeight * 100) / 100}
onChange={(v) => handleUpdate({ panicBarHeight: v })}
min={0.5}
max={node.height - 0.1}
precision={2}
step={0.05}
unit="m"
/>
</div>
)}
</PanelSection>
<PanelSection title="Segments">
{node.segments.map((seg, i) => {
const numCols = seg.columnRatios.length
const colSum = seg.columnRatios.reduce((a, b) => a + b, 0)
const normCols = seg.columnRatios.map(r => r / colSum)
return (
<div key={i} className="mb-2 flex flex-col gap-1">
<div className="flex items-center justify-between pb-1">
<span className="text-xs font-medium text-white/80">Segment {i + 1}</span>
</div>
<SegmentedControl
value={seg.type}
onChange={(t) => {
const updated = node.segments.map((s, idx) => idx === i ? { ...s, type: t } : s)
handleUpdate({ segments: updated })
}}
options={[
{ label: 'Panel', value: 'panel' },
{ label: 'Glass', value: 'glass' },
{ label: 'Empty', value: 'empty' },
]}
/>
<SliderControl
label="Height"
value={Math.round(normHeights[i]! * 100 * 10) / 10}
onChange={(v) => setSegmentHeightRatio(i, v / 100)}
min={5}
max={95}
precision={1}
step={1}
unit="%"
/>
<SliderControl
label="Columns"
value={numCols}
onChange={(v) => {
const n = Math.max(1, Math.min(8, Math.round(v)))
const updated = node.segments.map((s, idx) =>
idx === i ? { ...s, columnRatios: Array(n).fill(1 / n) } : s,
)
handleUpdate({ segments: updated })
}}
min={1}
max={8}
precision={0}
step={1}
/>
{numCols > 1 && (
<div className="mt-1 border-t border-border/50 pt-1">
{normCols.map((ratio, ci) => (
<SliderControl
key={`c-${ci}`}
label={`C${ci + 1}`}
value={Math.round(ratio * 100 * 10) / 10}
onChange={(v) => setSegmentColumnRatio(i, ci, v / 100)}
min={5}
max={95}
precision={1}
step={1}
unit="%"
/>
))}
<SliderControl
label="Divider"
value={Math.round(seg.dividerThickness * 1000) / 1000}
onChange={(v) => {
const updated = node.segments.map((s, idx) =>
idx === i ? { ...s, dividerThickness: v } : s,
)
handleUpdate({ segments: updated })
}}
min={0.005}
max={0.1}
precision={3}
step={0.005}
unit="m"
/>
</div>
)}
{seg.type === 'panel' && (
<div className="mt-1 border-t border-border/50 pt-1">
<SliderControl
label="Inset"
value={Math.round(seg.panelInset * 1000) / 1000}
onChange={(v) => {
const updated = node.segments.map((s, idx) =>
idx === i ? { ...s, panelInset: v } : s,
)
handleUpdate({ segments: updated })
}}
min={0}
max={0.1}
precision={3}
step={0.005}
unit="m"
/>
<SliderControl
label="Depth"
value={Math.round(seg.panelDepth * 1000) / 1000}
onChange={(v) => {
const updated = node.segments.map((s, idx) =>
idx === i ? { ...s, panelDepth: v } : s,
)
handleUpdate({ segments: updated })
}}
min={0}
max={0.1}
precision={3}
step={0.005}
unit="m"
/>
</div>
)}
</div>
)
})}
<div className="flex gap-1.5 px-1 pt-1">
<ActionButton
label="+ Add Segment"
onClick={() => {
const updated = [
...node.segments,
{ type: 'panel' as const, heightRatio: 1, columnRatios: [1], dividerThickness: 0.03, panelDepth: 0.01, panelInset: 0.04 },
]
handleUpdate({ segments: updated })
}}
/>
{node.segments.length > 1 && (
<ActionButton
label="- Remove"
onClick={() => handleUpdate({ segments: node.segments.slice(0, -1) })}
className="text-white/60 hover:text-white"
/>
)}
</div>
</PanelSection>
<PanelSection title="Actions">
<ActionGroup>
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
<ActionButton icon={<Copy className="h-3.5 w-3.5" />} label="Duplicate" onClick={handleDuplicate} />
<ActionButton
icon={<Trash2 className="h-3.5 w-3.5 text-red-400" />}
label="Delete"
onClick={handleDelete}
className="hover:bg-red-500/20"
/>
</ActionGroup>
</PanelSection>
</PanelWrapper>
)
}
@@ -1,257 +0,0 @@
'use client'
import { getScaledDimensions, type AnyNode, ItemNode, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { Copy, Link, Link2Off, Move, Trash2 } from 'lucide-react'
import { useCallback, useState } from 'react'
import useEditor from '@/store/use-editor'
import { sfxEmitter } from '@/lib/sfx-bus'
import { cn } from '@/lib/utils'
import { PanelWrapper } from './panel-wrapper'
import { PanelSection } from '../controls/panel-section'
import { SliderControl } from '../controls/slider-control'
import { ActionButton, ActionGroup } from '../controls/action-button'
import { CollectionsPopover } from './collections/collections-popover'
export function ItemPanel() {
const selectedIds = useViewer((s) => s.selection.selectedIds)
const setSelection = useViewer((s) => s.setSelection)
const nodes = useScene((s) => s.nodes)
const updateNode = useScene((s) => s.updateNode)
const deleteNode = useScene((s) => s.deleteNode)
const setMovingNode = useEditor((s) => s.setMovingNode)
const selectedId = selectedIds[0]
const node = selectedId
? (nodes[selectedId as AnyNode['id']] as ItemNode | undefined)
: undefined
const [uniformScale, setUniformScale] = useState(true)
const handleUpdate = useCallback(
(updates: Partial<ItemNode>) => {
if (!selectedId || !node) return
updateNode(selectedId as AnyNode['id'], updates)
if (node.asset.attachTo === 'wall' && node.parentId) {
requestAnimationFrame(() => {
useScene.getState().dirtyNodes.add(node.parentId as AnyNode['id'])
})
}
},
[selectedId, node, updateNode],
)
const handleClose = useCallback(() => {
setSelection({ selectedIds: [] })
}, [setSelection])
const handleMove = useCallback(() => {
if (node) {
sfxEmitter.emit('sfx:item-pick')
setMovingNode(node)
setSelection({ selectedIds: [] })
}
}, [node, setMovingNode, setSelection])
const handleDuplicate = useCallback(() => {
if (!node) return
sfxEmitter.emit('sfx:item-pick')
const proto = ItemNode.parse({
position: [...node.position] as [number, number, number],
rotation: [...node.rotation] as [number, number, number],
name: node.name,
asset: node.asset,
parentId: node.parentId,
side: node.side,
metadata: { isNew: true },
})
setMovingNode(proto)
setSelection({ selectedIds: [] })
}, [node, setMovingNode, setSelection])
const handleDelete = useCallback(() => {
if (!selectedId) return
sfxEmitter.emit('sfx:item-delete')
deleteNode(selectedId as AnyNode['id'])
setSelection({ selectedIds: [] })
}, [selectedId, deleteNode, setSelection])
if (!node || node.type !== 'item' || selectedIds.length !== 1) return null
return (
<PanelWrapper
title={node.name || node.asset.name}
icon={node.asset.thumbnail || '/icons/furniture.png'}
onClose={handleClose}
width={300}
>
<PanelSection title="Position">
<SliderControl
label={<>X<sub className="text-[11px] ml-[1px] opacity-70">pos</sub></>}
value={Math.round(node.position[0] * 100) / 100}
onChange={(value) => handleUpdate({ position: [value, node.position[1], node.position[2]] })}
min={node.position[0] - 2}
max={node.position[0] + 2}
precision={2}
step={0.01}
unit="m"
/>
<SliderControl
label={<>Y<sub className="text-[11px] ml-[1px] opacity-70">pos</sub></>}
value={Math.round(node.position[1] * 100) / 100}
onChange={(value) => handleUpdate({ position: [node.position[0], value, node.position[2]] })}
min={node.position[1] - 2}
max={node.position[1] + 2}
precision={2}
step={0.01}
unit="m"
/>
<SliderControl
label={<>Z<sub className="text-[11px] ml-[1px] opacity-70">pos</sub></>}
value={Math.round(node.position[2] * 100) / 100}
onChange={(value) => handleUpdate({ position: [node.position[0], node.position[1], value] })}
min={node.position[2] - 2}
max={node.position[2] + 2}
precision={2}
step={0.01}
unit="m"
/>
</PanelSection>
<PanelSection title="Rotation">
<SliderControl
label={<>Y<sub className="text-[11px] ml-[1px] opacity-70">rot</sub></>}
value={Math.round((node.rotation[1] * 180) / Math.PI)}
onChange={(degrees) => {
const radians = (degrees * Math.PI) / 180
handleUpdate({ rotation: [node.rotation[0], radians, node.rotation[2]] })
}}
min={Math.round((node.rotation[1] * 180) / Math.PI) - 45}
max={Math.round((node.rotation[1] * 180) / Math.PI) + 45}
precision={0}
step={1}
unit="°"
/>
<div className="flex gap-1.5 px-1 pt-2 pb-1">
<ActionButton
label="-45°"
onClick={() => {
sfxEmitter.emit('sfx:item-rotate')
const currentDegrees = (node.rotation[1] * 180) / Math.PI
const radians = ((currentDegrees - 45) * Math.PI) / 180
handleUpdate({ rotation: [node.rotation[0], radians, node.rotation[2]] })
}}
/>
<ActionButton
label="+45°"
onClick={() => {
sfxEmitter.emit('sfx:item-rotate')
const currentDegrees = (node.rotation[1] * 180) / Math.PI
const radians = ((currentDegrees + 45) * Math.PI) / 180
handleUpdate({ rotation: [node.rotation[0], radians, node.rotation[2]] })
}}
/>
</div>
</PanelSection>
<PanelSection title="Scale">
<div className="flex items-center justify-between px-2 pb-2">
<span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground/80">Uniform Scale</span>
<button
type="button"
className={cn(
"flex h-6 w-6 items-center justify-center rounded-md transition-colors text-muted-foreground hover:text-foreground",
uniformScale ? "bg-[#3e3e3e]" : "bg-[#2C2C2E] hover:bg-[#3e3e3e]"
)}
onClick={() => setUniformScale((v) => !v)}
>
{uniformScale ? <Link className="h-3.5 w-3.5" /> : <Link2Off className="h-3.5 w-3.5" />}
</button>
</div>
{uniformScale ? (
<SliderControl
label={<>XYZ<sub className="text-[11px] ml-[1px] opacity-70">scale</sub></>}
value={Math.round(node.scale[0] * 100) / 100}
onChange={(value) => {
const v = Math.max(0.01, value)
handleUpdate({ scale: [v, v, v] })
}}
min={0.01}
max={10}
precision={2}
step={0.1}
/>
) : (
<>
<SliderControl
label={<>X<sub className="text-[11px] ml-[1px] opacity-70">scale</sub></>}
value={Math.round(node.scale[0] * 100) / 100}
onChange={(value) => handleUpdate({ scale: [Math.max(0.01, value), node.scale[1], node.scale[2]] })}
min={0.01}
max={10}
precision={2}
step={0.1}
/>
<SliderControl
label={<>Y<sub className="text-[11px] ml-[1px] opacity-70">scale</sub></>}
value={Math.round(node.scale[1] * 100) / 100}
onChange={(value) => handleUpdate({ scale: [node.scale[0], Math.max(0.01, value), node.scale[2]] })}
min={0.01}
max={10}
precision={2}
step={0.1}
/>
<SliderControl
label={<>Z<sub className="text-[11px] ml-[1px] opacity-70">scale</sub></>}
value={Math.round(node.scale[2] * 100) / 100}
onChange={(value) => handleUpdate({ scale: [node.scale[0], node.scale[1], Math.max(0.01, value)] })}
min={0.01}
max={10}
precision={2}
step={0.1}
/>
</>
)}
</PanelSection>
<PanelSection title="Info">
<div className="flex items-center justify-between px-2 py-1 text-sm text-muted-foreground">
<span>Dimensions</span>
{(() => {
const [w, h, d] = getScaledDimensions(node)
return (
<span className="font-mono text-white">
{Math.round(w * 100) / 100}×{Math.round(h * 100) / 100}×{Math.round(d * 100) / 100}
</span>
)
})()}
</div>
</PanelSection>
<PanelSection title="Collections">
<ActionGroup>
<CollectionsPopover nodeId={selectedId as AnyNode['id']} collectionIds={node.collectionIds}>
<ActionButton label="Manage collections…" />
</CollectionsPopover>
</ActionGroup>
</PanelSection>
<PanelSection title="Actions">
<ActionGroup>
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
<ActionButton icon={<Copy className="h-3.5 w-3.5" />} label="Duplicate" onClick={handleDuplicate} />
<ActionButton
icon={<Trash2 className="h-3.5 w-3.5 text-red-400" />}
label="Delete"
onClick={handleDelete}
className="hover:bg-red-500/20"
/>
</ActionGroup>
</PanelSection>
</PanelWrapper>
)
}
@@ -1,50 +0,0 @@
'use client'
import { AnyNodeId, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import useEditor from '@/store/use-editor'
import { CeilingPanel } from './ceiling-panel'
import { ItemPanel } from './item-panel'
import { ReferencePanel } from './reference-panel'
import { RoofPanel } from './roof-panel'
import { SlabPanel } from './slab-panel'
import { WallPanel } from './wall-panel'
import { DoorPanel } from './door-panel'
import { WindowPanel } from './window-panel'
export function PanelManager() {
const selectedIds = useViewer((s) => s.selection.selectedIds)
const selectedReferenceId = useEditor((s) => s.selectedReferenceId)
const nodes = useScene((s) => s.nodes)
// Show reference panel if a reference is selected
if (selectedReferenceId) {
return <ReferencePanel />
}
// Show appropriate panel based on selected node type
if (selectedIds.length === 1) {
const selectedNode = selectedIds[0]
const node = nodes[selectedNode as AnyNodeId]
if (node) {
switch (node.type) {
case 'item':
return <ItemPanel />
case 'roof':
return <RoofPanel />
case 'slab':
return <SlabPanel />
case 'ceiling':
return <CeilingPanel />
case 'wall':
return <WallPanel />
case 'door':
return <DoorPanel />
case 'window':
return <WindowPanel />
}
}
}
return null
}
@@ -1,79 +0,0 @@
'use client'
import { cn } from '@/lib/utils'
import { X, RotateCcw, Moon } from 'lucide-react'
import Image from 'next/image'
interface PanelWrapperProps {
title: string
icon?: string
onClose?: () => void
onReset?: () => void
children: React.ReactNode
className?: string
width?: number | string
}
export function PanelWrapper({
title,
icon,
onClose,
onReset,
children,
className,
width = 320, // default width
}: PanelWrapperProps) {
return (
<div
className={cn(
"pointer-events-auto fixed right-4 top-20 z-50 flex flex-col overflow-hidden rounded-xl border border-border/50 bg-sidebar/95 shadow-2xl backdrop-blur-xl dark:text-foreground max-h-[calc(100dvh-100px)]",
className
)}
style={{ width }}
>
{/* Header */}
<div className="flex items-center justify-between px-3 py-3 border-b border-border/50">
<div className="flex items-center gap-2">
{icon && (
<Image
src={icon}
alt=""
width={16}
height={16}
className="shrink-0 object-contain"
/>
)}
<h2 className="font-semibold text-foreground text-sm truncate tracking-tight">
{title}
</h2>
</div>
<div className="flex items-center gap-1">
{onReset && (
<button
type="button"
onClick={onReset}
className="flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors bg-[#2C2C2E] hover:bg-[#3e3e3e] hover:text-foreground"
>
<RotateCcw className="h-4 w-4" />
</button>
)}
{onClose && (
<button
type="button"
onClick={onClose}
className="flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors bg-[#2C2C2E] hover:bg-[#3e3e3e] hover:text-foreground"
>
<X className="h-4 w-4" />
</button>
)}
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto min-h-0 no-scrollbar flex flex-col">
{children}
</div>
</div>
)
}
@@ -1,458 +0,0 @@
'use client'
import { useEffect, useState, useCallback } from 'react'
import { BookMarked, Check, Globe, GlobeLock, MoreHorizontal, Pencil, Plus, Save, Trash2, Users, X } from 'lucide-react'
import { emitter } from '@pascal-app/core'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/primitives/dropdown-menu'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/primitives/popover'
import { useAuth } from '@/features/community/lib/auth/hooks'
import { cn } from '@/lib/utils'
export type PresetType = 'door' | 'window'
export interface PresetData {
id: string
type: string
name: string
data: Record<string, unknown>
thumbnail_url: string | null
user_id: string | null
is_community: boolean
created_at: string
}
type Tab = 'community' | 'mine'
interface PresetsPopoverProps {
type: PresetType
/** Apply preset data to the current node */
onApply: (data: Record<string, unknown>) => void
/** Save current node state as a new preset with the given name */
onSave: (name: string) => Promise<void>
/** Overwrite an existing preset's data with the current node state */
onOverwrite: (id: string) => Promise<void>
children: React.ReactNode
}
export function PresetsPopover({ type, onApply, onSave, onOverwrite, children }: PresetsPopoverProps) {
const { isAuthenticated } = useAuth()
const [open, setOpen] = useState(false)
const [tab, setTab] = useState<Tab>('community')
const [presets, setPresets] = useState<PresetData[]>([])
const [loading, setLoading] = useState(false)
// New preset save state
const [showSaveInput, setShowSaveInput] = useState(false)
const [saveName, setSaveName] = useState('')
const [saving, setSaving] = useState(false)
// Rename state
const [renamingId, setRenamingId] = useState<string | null>(null)
const [renameValue, setRenameValue] = useState('')
// Delete confirmation
const [deletingId, setDeletingId] = useState<string | null>(null)
// Overwrite feedback (shows check icon briefly after overwrite)
const [overwrittenId, setOverwrittenId] = useState<string | null>(null)
const fetchPresets = useCallback(async () => {
setLoading(true)
try {
const res = await fetch(`/api/presets?type=${type}&tab=${tab}`)
if (res.ok) {
const json = await res.json()
setPresets(json.presets ?? [])
}
} finally {
setLoading(false)
}
}, [type, tab])
useEffect(() => {
if (open) fetchPresets()
}, [open, fetchPresets])
useEffect(() => {
if (!isAuthenticated && tab === 'mine') setTab('community')
}, [isAuthenticated, tab])
useEffect(() => {
const handler = ({ presetId, thumbnailUrl }: { presetId: string; thumbnailUrl: string }) => {
setPresets((prev) =>
prev.map((p) => (p.id === presetId ? { ...p, thumbnail_url: thumbnailUrl } : p)),
)
}
emitter.on('preset:thumbnail-updated', handler)
return () => emitter.off('preset:thumbnail-updated', handler)
}, [])
const handleSaveNew = async () => {
if (!saveName.trim()) return
setSaving(true)
try {
await onSave(saveName.trim())
setSaveName('')
setShowSaveInput(false)
if (tab === 'mine') fetchPresets()
else setTab('mine')
} finally {
setSaving(false)
}
}
const handleRename = async (id: string) => {
if (!renameValue.trim()) return
const res = await fetch(`/api/presets/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: renameValue.trim() }),
})
if (res.ok) {
setPresets((prev) => prev.map((p) => (p.id === id ? { ...p, name: renameValue.trim() } : p)))
setRenamingId(null)
}
}
const handleDelete = async (id: string) => {
const res = await fetch(`/api/presets/${id}`, { method: 'DELETE' })
if (res.ok) {
setPresets((prev) => prev.filter((p) => p.id !== id))
setDeletingId(null)
}
}
const handleOverwrite = async (id: string) => {
await onOverwrite(id)
setOverwrittenId(id)
setTimeout(() => setOverwrittenId(null), 1500)
}
const handleToggleCommunity = async (id: string, current: boolean) => {
const res = await fetch(`/api/presets/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ is_community: !current }),
})
if (res.ok) {
setPresets((prev) => prev.map((p) => (p.id === id ? { ...p, is_community: !current } : p)))
}
}
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>{children}</PopoverTrigger>
<PopoverContent
side="left"
align="start"
sideOffset={8}
className="w-72 p-0 border-border/50 bg-sidebar/95 backdrop-blur-xl shadow-2xl rounded-xl overflow-hidden"
>
{/* Header */}
<div className="flex items-center justify-between px-3 py-2.5 border-b border-border/50">
<div className="flex items-center gap-1.5">
<BookMarked className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-xs font-semibold text-foreground tracking-tight">
{type === 'door' ? 'Door' : 'Window'} Presets
</span>
</div>
{isAuthenticated && (
<button
onClick={() => { setShowSaveInput((v) => !v); setSaveName('') }}
className="flex items-center gap-1 rounded-md px-2 py-1 text-[11px] font-medium text-muted-foreground hover:text-foreground hover:bg-white/10 transition-colors"
>
<Plus className="h-3 w-3" />
Save new
</button>
)}
</div>
{/* New preset name input */}
{showSaveInput && (
<div className="flex items-center gap-1.5 px-3 py-2 border-b border-border/50 bg-white/5">
<input
autoFocus
value={saveName}
onChange={(e) => setSaveName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleSaveNew()
if (e.key === 'Escape') { setShowSaveInput(false); setSaveName('') }
}}
placeholder="Preset name…"
className="flex-1 min-w-0 rounded-md border border-border/50 bg-background/50 px-2 py-1 text-xs text-foreground placeholder:text-muted-foreground/60 outline-none focus:border-ring focus:ring-1 focus:ring-ring/30"
/>
<button
disabled={!saveName.trim() || saving}
onClick={handleSaveNew}
className="flex h-6 w-6 items-center justify-center rounded-md bg-primary/20 hover:bg-primary/30 text-primary disabled:opacity-40 transition-colors"
>
<Check className="h-3.5 w-3.5" />
</button>
<button
onClick={() => { setShowSaveInput(false); setSaveName('') }}
className="flex h-6 w-6 items-center justify-center rounded-md hover:bg-white/10 text-muted-foreground transition-colors"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
)}
{/* Tabs */}
<div className="flex border-b border-border/50">
<TabButton active={tab === 'community'} onClick={() => setTab('community')}>
<Users className="h-3 w-3" />
Community
</TabButton>
<TabButton
active={tab === 'mine'}
onClick={() => { if (isAuthenticated) setTab('mine') }}
disabled={!isAuthenticated}
>
<BookMarked className="h-3 w-3" />
My presets
</TabButton>
</div>
{/* Content */}
<div className="max-h-72 overflow-y-auto no-scrollbar">
{loading ? (
<div className="flex items-center justify-center py-8">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-border border-t-foreground" />
</div>
) : presets.length === 0 ? (
<EmptyState tab={tab} isAuthenticated={isAuthenticated} />
) : (
<ul className="divide-y divide-border/30">
{presets.map((preset) => (
<PresetRow
key={preset.id}
preset={preset}
isMine={tab === 'mine'}
renamingId={renamingId}
renameValue={renameValue}
deletingId={deletingId}
overwrittenId={overwrittenId}
onApply={() => { onApply(preset.data); setOpen(false) }}
onOverwrite={() => handleOverwrite(preset.id)}
onToggleCommunity={() => handleToggleCommunity(preset.id, preset.is_community)}
onStartRename={() => { setRenamingId(preset.id); setRenameValue(preset.name) }}
onRenameChange={setRenameValue}
onRenameConfirm={() => handleRename(preset.id)}
onRenameCancel={() => setRenamingId(null)}
onDeleteRequest={() => setDeletingId(preset.id)}
onDeleteConfirm={() => handleDelete(preset.id)}
onDeleteCancel={() => setDeletingId(null)}
/>
))}
</ul>
)}
</div>
</PopoverContent>
</Popover>
)
}
function TabButton({
active,
onClick,
disabled,
children,
}: {
active: boolean
onClick: () => void
disabled?: boolean
children: React.ReactNode
}) {
return (
<button
onClick={onClick}
disabled={disabled}
className={cn(
'flex flex-1 items-center justify-center gap-1.5 py-2 text-[11px] font-medium transition-colors',
active
? 'text-foreground border-b-2 border-primary -mb-px'
: 'text-muted-foreground hover:text-foreground',
disabled && 'opacity-40 cursor-not-allowed',
)}
>
{children}
</button>
)
}
function EmptyState({ tab, isAuthenticated }: { tab: Tab; isAuthenticated: boolean }) {
return (
<div className="flex flex-col items-center justify-center gap-2 py-8 text-center px-4">
<BookMarked className="h-6 w-6 text-muted-foreground/40" />
<p className="text-xs text-muted-foreground">
{tab === 'community'
? 'No community presets yet.'
: isAuthenticated
? 'No presets saved yet. Use "Save new" to save the current configuration.'
: 'Sign in to save and view your presets.'}
</p>
</div>
)
}
interface PresetRowProps {
preset: PresetData
isMine: boolean
renamingId: string | null
renameValue: string
deletingId: string | null
overwrittenId: string | null
onApply: () => void
onOverwrite: () => void
onToggleCommunity: () => void
onStartRename: () => void
onRenameChange: (v: string) => void
onRenameConfirm: () => void
onRenameCancel: () => void
onDeleteRequest: () => void
onDeleteConfirm: () => void
onDeleteCancel: () => void
}
function PresetRow({
preset,
isMine,
renamingId,
renameValue,
deletingId,
overwrittenId,
onApply,
onOverwrite,
onToggleCommunity,
onStartRename,
onRenameChange,
onRenameConfirm,
onRenameCancel,
onDeleteRequest,
onDeleteConfirm,
onDeleteCancel,
}: PresetRowProps) {
const isRenaming = renamingId === preset.id
const isDeleting = deletingId === preset.id
const justOverwritten = overwrittenId === preset.id
if (isDeleting) {
return (
<li className="flex items-center justify-between gap-2 px-3 py-2.5 bg-red-500/10">
<span className="text-xs text-foreground/80 truncate">Delete "{preset.name}"?</span>
<div className="flex items-center gap-1 shrink-0">
<button
onClick={onDeleteConfirm}
className="rounded-md px-2 py-0.5 text-[11px] font-medium bg-red-500/20 text-red-400 hover:bg-red-500/30 transition-colors"
>
Delete
</button>
<button
onClick={onDeleteCancel}
className="rounded-md px-2 py-0.5 text-[11px] font-medium hover:bg-white/10 text-muted-foreground transition-colors"
>
Cancel
</button>
</div>
</li>
)
}
if (isRenaming) {
return (
<li className="flex items-center gap-1.5 px-3 py-2">
<input
autoFocus
value={renameValue}
onChange={(e) => onRenameChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') onRenameConfirm()
if (e.key === 'Escape') onRenameCancel()
}}
className="flex-1 min-w-0 rounded-md border border-border/50 bg-background/50 px-2 py-1 text-xs text-foreground outline-none focus:border-ring focus:ring-1 focus:ring-ring/30"
/>
<button
onClick={onRenameConfirm}
className="flex h-6 w-6 items-center justify-center rounded-md bg-primary/20 hover:bg-primary/30 text-primary transition-colors"
>
<Check className="h-3.5 w-3.5" />
</button>
<button
onClick={onRenameCancel}
className="flex h-6 w-6 items-center justify-center rounded-md hover:bg-white/10 text-muted-foreground transition-colors"
>
<X className="h-3.5 w-3.5" />
</button>
</li>
)
}
return (
<li className="group flex items-center gap-2 px-3 py-2.5 hover:bg-white/5 transition-colors">
{/* Thumbnail */}
<div className="h-12 w-12 shrink-0 rounded-md border border-border/40 bg-white/5 overflow-hidden">
{preset.thumbnail_url ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={preset.thumbnail_url} alt={preset.name} className="h-full w-full object-cover" />
) : (
<div className="h-full w-full flex items-center justify-center">
<div className="h-3 w-5 rounded-sm border border-muted-foreground/30" />
</div>
)}
</div>
{/* Name + date — clicking applies */}
<button onClick={onApply} className="flex-1 min-w-0 text-left">
<span className="flex items-center gap-1.5">
<span className="block truncate text-xs font-medium text-foreground group-hover:text-foreground/90">
{preset.name}
</span>
{/* Only show globe in "My presets" — in community tab it's redundant */}
{isMine && preset.is_community && (
<Globe className="h-2.5 w-2.5 shrink-0 text-muted-foreground/50" />
)}
</span>
<span className="block text-[10px] text-muted-foreground/60">
{new Date(preset.created_at).toLocaleDateString()}
</span>
</button>
{/* Actions — 3-dot dropdown, only in "My presets" */}
{isMine && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className={cn(
'flex h-6 w-6 shrink-0 items-center justify-center rounded-md transition-colors opacity-0 group-hover:opacity-100',
justOverwritten
? 'text-green-400 bg-green-500/10 opacity-100'
: 'text-muted-foreground hover:text-foreground hover:bg-white/10',
)}
>
{justOverwritten ? <Check className="h-3 w-3" /> : <MoreHorizontal className="h-3.5 w-3.5" />}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent side="left" align="start" className="min-w-44">
<DropdownMenuItem onClick={onOverwrite}>
<Save className="h-3.5 w-3.5" />
Update with current
</DropdownMenuItem>
<DropdownMenuItem onClick={onToggleCommunity}>
{preset.is_community
? <><GlobeLock className="h-3.5 w-3.5" />Remove from community</>
: <><Globe className="h-3.5 w-3.5" />Share with community</>}
</DropdownMenuItem>
<DropdownMenuItem onClick={onStartRename}>
<Pencil className="h-3.5 w-3.5" />
Rename
</DropdownMenuItem>
<DropdownMenuItem variant="destructive" onClick={onDeleteRequest}>
<Trash2 className="h-3.5 w-3.5" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</li>
)
}
@@ -1,150 +0,0 @@
'use client'
import { type AnyNode, type GuideNode, type ScanNode, useScene } from '@pascal-app/core'
import { Box, Image as ImageIcon } from 'lucide-react'
import { useCallback } from 'react'
import useEditor from '@/store/use-editor'
import { PanelWrapper } from './panel-wrapper'
import { PanelSection } from '../controls/panel-section'
import { SliderControl } from '../controls/slider-control'
import { MetricControl } from '../controls/metric-control'
import { ActionButton, ActionGroup } from '../controls/action-button'
type ReferenceNode = ScanNode | GuideNode
export function ReferencePanel() {
const selectedReferenceId = useEditor((s) => s.selectedReferenceId)
const setSelectedReferenceId = useEditor((s) => s.setSelectedReferenceId)
const nodes = useScene((s) => s.nodes)
const updateNode = useScene((s) => s.updateNode)
const node = selectedReferenceId
? (nodes[selectedReferenceId as AnyNode['id']] as ReferenceNode | undefined)
: undefined
const handleUpdate = useCallback(
(updates: Partial<ReferenceNode>) => {
if (!selectedReferenceId) return
updateNode(selectedReferenceId as AnyNode['id'], updates)
},
[selectedReferenceId, updateNode],
)
const handleClose = useCallback(() => {
setSelectedReferenceId(null)
}, [setSelectedReferenceId])
if (!node || (node.type !== 'scan' && node.type !== 'guide')) return null
const isScan = node.type === 'scan'
return (
<PanelWrapper
title={node.name || (isScan ? '3D Scan' : 'Guide Image')}
icon={isScan ? undefined : undefined}
onClose={handleClose}
width={300}
>
<PanelSection title="Position">
<SliderControl
label={<>X<sub className="text-[11px] ml-[1px] opacity-70">pos</sub></>}
value={Math.round(node.position[0] * 100) / 100}
onChange={(value) => {
const pos = [...node.position] as [number, number, number]
pos[0] = value
handleUpdate({ position: pos })
}}
min={-50}
max={50}
precision={2}
step={0.1}
unit="m"
/>
<SliderControl
label={<>Y<sub className="text-[11px] ml-[1px] opacity-70">pos</sub></>}
value={Math.round(node.position[1] * 100) / 100}
onChange={(value) => {
const pos = [...node.position] as [number, number, number]
pos[1] = value
handleUpdate({ position: pos })
}}
min={-50}
max={50}
precision={2}
step={0.1}
unit="m"
/>
<SliderControl
label={<>Z<sub className="text-[11px] ml-[1px] opacity-70">pos</sub></>}
value={Math.round(node.position[2] * 100) / 100}
onChange={(value) => {
const pos = [...node.position] as [number, number, number]
pos[2] = value
handleUpdate({ position: pos })
}}
min={-50}
max={50}
precision={2}
step={0.1}
unit="m"
/>
</PanelSection>
<PanelSection title="Rotation">
<SliderControl
label={<>Y<sub className="text-[11px] ml-[1px] opacity-70">rot</sub></>}
value={Math.round((node.rotation[1] * 180) / Math.PI)}
onChange={(degrees) => {
const radians = (degrees * Math.PI) / 180
handleUpdate({
rotation: [node.rotation[0], radians, node.rotation[2]],
})
}}
min={-180}
max={180}
precision={0}
step={1}
unit="°"
/>
<div className="flex gap-1.5 px-1 pt-2 pb-1">
<ActionButton
label="-45°"
onClick={() => handleUpdate({ rotation: [node.rotation[0], node.rotation[1] - Math.PI / 4, node.rotation[2]] })}
/>
<ActionButton
label="+45°"
onClick={() => handleUpdate({ rotation: [node.rotation[0], node.rotation[1] + Math.PI / 4, node.rotation[2]] })}
/>
</div>
</PanelSection>
<PanelSection title="Scale & Opacity">
<SliderControl
label={<>XYZ<sub className="text-[11px] ml-[1px] opacity-70">scale</sub></>}
value={Math.round(node.scale * 100) / 100}
onChange={(value) => {
if (value > 0) {
handleUpdate({ scale: value })
}
}}
min={0.01}
max={10}
precision={2}
step={0.1}
/>
<SliderControl
label="Opacity"
value={node.opacity}
onChange={(v) => handleUpdate({ opacity: v })}
min={0}
max={100}
precision={0}
step={1}
unit="%"
/>
</PanelSection>
</PanelWrapper>
)
}
@@ -1,169 +0,0 @@
'use client'
import { type AnyNode, type RoofNode, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useCallback } from 'react'
import { PanelWrapper } from './panel-wrapper'
import { PanelSection } from '../controls/panel-section'
import { SliderControl } from '../controls/slider-control'
import { MetricControl } from '../controls/metric-control'
import { ActionButton } from '../controls/action-button'
export function RoofPanel() {
const selectedIds = useViewer((s) => s.selection.selectedIds)
const setSelection = useViewer((s) => s.setSelection)
const nodes = useScene((s) => s.nodes)
const updateNode = useScene((s) => s.updateNode)
const selectedId = selectedIds[0]
const node = selectedId
? (nodes[selectedId as AnyNode['id']] as RoofNode | undefined)
: undefined
const handleUpdate = useCallback(
(updates: Partial<RoofNode>) => {
if (!selectedId) return
updateNode(selectedId as AnyNode['id'], updates)
},
[selectedId, updateNode],
)
const handleClose = useCallback(() => {
setSelection({ selectedIds: [] })
}, [setSelection])
if (!node || node.type !== 'roof' || selectedIds.length !== 1) return null
const totalWidth = node.leftWidth + node.rightWidth
return (
<PanelWrapper
title={node.name || "Roof"}
icon="/icons/roof.png"
onClose={handleClose}
width={300}
>
<PanelSection title="Dimensions">
<SliderControl
label="Length"
value={Math.round(node.length * 100) / 100}
onChange={(v) => handleUpdate({ length: v })}
min={0.5}
max={20}
precision={2}
step={0.5}
unit="m"
/>
<SliderControl
label="Height"
value={Math.round(node.height * 100) / 100}
onChange={(v) => handleUpdate({ height: v })}
min={0.1}
max={10}
precision={2}
step={0.1}
unit="m"
/>
</PanelSection>
<PanelSection title="Slope Widths">
<div className="flex items-center justify-between px-2 pb-2 text-[10px] font-medium uppercase tracking-wider text-muted-foreground/80">
<span>Widths</span>
<span>Total: {totalWidth.toFixed(1)}m</span>
</div>
<SliderControl
label="Left"
value={Math.round(node.leftWidth * 100) / 100}
onChange={(v) => handleUpdate({ leftWidth: v })}
min={0.1}
max={10}
precision={2}
step={0.1}
unit="m"
/>
<SliderControl
label="Right"
value={Math.round(node.rightWidth * 100) / 100}
onChange={(v) => handleUpdate({ rightWidth: v })}
min={0.1}
max={10}
precision={2}
step={0.1}
unit="m"
/>
</PanelSection>
<PanelSection title="Rotation">
<SliderControl
label={<>R<sub className="text-[11px] ml-[1px] opacity-70">rot</sub></>}
value={Math.round((node.rotation * 180) / Math.PI)}
onChange={(degrees) => {
const radians = (degrees * Math.PI) / 180
handleUpdate({ rotation: radians })
}}
min={-180}
max={180}
precision={0}
step={1}
unit="°"
/>
<div className="flex gap-1.5 px-1 pt-2 pb-1">
<ActionButton
label="-90°"
onClick={() => handleUpdate({ rotation: node.rotation - Math.PI / 2 })}
/>
<ActionButton
label="+90°"
onClick={() => handleUpdate({ rotation: node.rotation + Math.PI / 2 })}
/>
</div>
</PanelSection>
<PanelSection title="Position">
<SliderControl
label={<>X<sub className="text-[11px] ml-[1px] opacity-70">pos</sub></>}
value={Math.round(node.position[0] * 100) / 100}
onChange={(v) => {
const pos = [...node.position] as [number, number, number]
pos[0] = v
handleUpdate({ position: pos })
}}
min={-50}
max={50}
precision={2}
step={0.1}
unit="m"
/>
<SliderControl
label={<>Y<sub className="text-[11px] ml-[1px] opacity-70">pos</sub></>}
value={Math.round(node.position[1] * 100) / 100}
onChange={(v) => {
const pos = [...node.position] as [number, number, number]
pos[1] = v
handleUpdate({ position: pos })
}}
min={-50}
max={50}
precision={2}
step={0.1}
unit="m"
/>
<SliderControl
label={<>Z<sub className="text-[11px] ml-[1px] opacity-70">pos</sub></>}
value={Math.round(node.position[2] * 100) / 100}
onChange={(v) => {
const pos = [...node.position] as [number, number, number]
pos[2] = v
handleUpdate({ position: pos })
}}
min={-50}
max={50}
precision={2}
step={0.1}
unit="m"
/>
</PanelSection>
</PanelWrapper>
)
}
@@ -1,219 +0,0 @@
'use client'
import { type AnyNode, type SlabNode, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { Edit, Plus, Trash2 } from 'lucide-react'
import { useCallback, useEffect } from 'react'
import useEditor from '@/store/use-editor'
import { PanelWrapper } from './panel-wrapper'
import { PanelSection } from '../controls/panel-section'
import { SliderControl } from '../controls/slider-control'
import { ActionButton, ActionGroup } from '../controls/action-button'
export function SlabPanel() {
const selectedIds = useViewer((s) => s.selection.selectedIds)
const setSelection = useViewer((s) => s.setSelection)
const nodes = useScene((s) => s.nodes)
const updateNode = useScene((s) => s.updateNode)
const editingHole = useEditor((s) => s.editingHole)
const setEditingHole = useEditor((s) => s.setEditingHole)
const selectedId = selectedIds[0]
const node = selectedId
? (nodes[selectedId as AnyNode['id']] as SlabNode | undefined)
: undefined
const handleUpdate = useCallback(
(updates: Partial<SlabNode>) => {
if (!selectedId) return
updateNode(selectedId as AnyNode['id'], updates)
},
[selectedId, updateNode],
)
const handleClose = useCallback(() => {
setSelection({ selectedIds: [] })
setEditingHole(null)
}, [setSelection, setEditingHole])
useEffect(() => {
if (!node) {
setEditingHole(null)
}
}, [node, setEditingHole])
useEffect(() => {
return () => {
setEditingHole(null)
}
}, [setEditingHole])
const handleAddHole = useCallback(() => {
if (!node || !selectedId) return
const polygon = node.polygon
let cx = 0
let cz = 0
for (const [x, z] of polygon) {
cx += x
cz += z
}
cx /= polygon.length
cz /= polygon.length
const holeSize = 0.5
const newHole: Array<[number, number]> = [
[cx - holeSize, cz - holeSize],
[cx + holeSize, cz - holeSize],
[cx + holeSize, cz + holeSize],
[cx - holeSize, cz + holeSize],
]
const currentHoles = node?.holes || []
handleUpdate({ holes: [...currentHoles, newHole] })
setEditingHole({ nodeId: selectedId, holeIndex: currentHoles.length })
}, [node, selectedId, handleUpdate, setEditingHole])
const handleEditHole = useCallback(
(index: number) => {
if (!selectedId) return
setEditingHole({ nodeId: selectedId, holeIndex: index })
},
[selectedId, setEditingHole],
)
const handleDeleteHole = useCallback(
(index: number) => {
if (!selectedId) return
const currentHoles = node?.holes || []
const newHoles = currentHoles.filter((_, i) => i !== index)
handleUpdate({ holes: newHoles })
if (editingHole?.nodeId === selectedId && editingHole?.holeIndex === index) {
setEditingHole(null)
}
},
[selectedId, node?.holes, handleUpdate, editingHole, setEditingHole],
)
if (!node || node.type !== 'slab' || selectedIds.length !== 1) return null
const calculateArea = (polygon: Array<[number, number]>): number => {
if (polygon.length < 3) return 0
let area = 0
const n = polygon.length
for (let i = 0; i < n; i++) {
const j = (i + 1) % n
area += polygon[i]![0] * polygon[j]![1]
area -= polygon[j]![0] * polygon[i]![1]
}
return Math.abs(area) / 2
}
const area = calculateArea(node.polygon)
return (
<PanelWrapper
title={node.name || "Slab"}
icon="/icons/floor.png"
onClose={handleClose}
width={320}
>
<PanelSection title="Elevation">
<SliderControl
label="Height"
value={Math.round(node.elevation * 1000) / 1000}
onChange={(v) => handleUpdate({ elevation: v })}
min={-1}
max={1}
precision={3}
step={0.01}
unit="m"
/>
<div className="mt-2 grid grid-cols-2 gap-1.5 px-1 pb-1">
<ActionButton label="Sunken (-15cm)" onClick={() => handleUpdate({ elevation: -0.15 })} />
<ActionButton label="Ground (0m)" onClick={() => handleUpdate({ elevation: 0 })} />
<ActionButton label="Raised (+5cm)" onClick={() => handleUpdate({ elevation: 0.05 })} />
<ActionButton label="Step (+15cm)" onClick={() => handleUpdate({ elevation: 0.15 })} />
</div>
</PanelSection>
<PanelSection title="Info">
<div className="flex items-center justify-between px-2 py-1 text-sm text-muted-foreground">
<span>Area</span>
<span className="font-mono text-white">{area.toFixed(2)} m²</span>
</div>
</PanelSection>
<PanelSection title="Holes">
{node.holes && node.holes.length > 0 ? (
<div className="flex flex-col gap-1 pb-2">
{node.holes.map((hole, index) => {
const holeArea = calculateArea(hole)
const isEditing = editingHole?.nodeId === selectedId && editingHole?.holeIndex === index
return (
<div
key={index}
className={`flex items-center justify-between rounded-lg border p-2 transition-colors ${
isEditing
? 'border-primary/50 bg-primary/10'
: 'border-transparent hover:bg-accent/30'
}`}
>
<div className="flex-1 min-w-0">
<p className={`text-xs font-medium ${isEditing ? 'text-primary' : 'text-white'}`}>
Hole {index + 1} {isEditing && '(Editing)'}
</p>
<p className="text-[10px] text-muted-foreground">
{holeArea.toFixed(2)} m² · {hole.length} pts
</p>
</div>
<div className="flex items-center gap-1">
{isEditing ? (
<ActionButton
label="Done"
onClick={() => setEditingHole(null)}
className="h-7 bg-primary text-primary-foreground hover:bg-primary/90"
/>
) : (
<>
<button
type="button"
className="flex h-7 w-7 items-center justify-center rounded-md bg-[#2C2C2E] text-muted-foreground hover:bg-[#3e3e3e] hover:text-foreground"
onClick={() => handleEditHole(index)}
>
<Edit className="h-3.5 w-3.5" />
</button>
<button
type="button"
className="flex h-7 w-7 items-center justify-center rounded-md bg-red-500/10 text-red-400 hover:bg-red-500/20 hover:text-red-300"
onClick={() => handleDeleteHole(index)}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</>
)}
</div>
</div>
)
})}
</div>
) : (
<div className="px-2 py-3 text-center text-xs text-muted-foreground">
No holes
</div>
)}
<div className="px-1 pt-1 pb-1">
<ActionButton
icon={<Plus className="h-3.5 w-3.5" />}
label="Add Hole"
onClick={handleAddHole}
className="w-full"
disabled={editingHole?.nodeId === selectedId}
/>
</div>
</PanelSection>
</PanelWrapper>
)
}
@@ -1,82 +0,0 @@
'use client'
import { type AnyNode, type AnyNodeId, type WallNode, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useCallback } from 'react'
import { PanelWrapper } from './panel-wrapper'
import { PanelSection } from '../controls/panel-section'
import { SliderControl } from '../controls/slider-control'
export function WallPanel() {
const selectedIds = useViewer((s) => s.selection.selectedIds)
const setSelection = useViewer((s) => s.setSelection)
const nodes = useScene((s) => s.nodes)
const updateNode = useScene((s) => s.updateNode)
const selectedId = selectedIds[0]
const node = selectedId
? (nodes[selectedId as AnyNode['id']] as WallNode | undefined)
: undefined
const handleUpdate = useCallback(
(updates: Partial<WallNode>) => {
if (!selectedId) return
updateNode(selectedId as AnyNode['id'], updates)
useScene.getState().dirtyNodes.add(selectedId as AnyNodeId)
},
[selectedId, updateNode],
)
const handleClose = useCallback(() => {
setSelection({ selectedIds: [] })
}, [setSelection])
if (!node || node.type !== 'wall' || selectedIds.length !== 1) return null
const dx = node.end[0] - node.start[0]
const dz = node.end[1] - node.start[1]
const length = Math.sqrt(dx * dx + dz * dz)
const height = node.height ?? 2.5
const thickness = node.thickness ?? 0.1
return (
<PanelWrapper
title={node.name || "Wall"}
icon="/icons/wall.png"
onClose={handleClose}
width={280}
>
<PanelSection title="Dimensions">
<SliderControl
label="Height"
value={Math.round(height * 100) / 100}
onChange={(v) => handleUpdate({ height: Math.max(0.1, v) })}
min={0.1}
max={6}
precision={2}
step={0.1}
unit="m"
/>
<SliderControl
label="Thickness"
value={Math.round(thickness * 1000) / 1000}
onChange={(v) => handleUpdate({ thickness: Math.max(0.05, v) })}
min={0.05}
max={1}
precision={3}
step={0.01}
unit="m"
/>
</PanelSection>
<PanelSection title="Info">
<div className="flex items-center justify-between px-2 py-1 text-sm text-muted-foreground">
<span>Length</span>
<span className="font-mono text-white">{length.toFixed(2)} m</span>
</div>
</PanelSection>
</PanelWrapper>
)
}
@@ -1,407 +0,0 @@
'use client'
import { type AnyNode, type AnyNodeId, WindowNode, emitter, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { BookMarked, Copy, FlipHorizontal2, Move, Trash2 } from 'lucide-react'
import { useCallback } from 'react'
import { sfxEmitter } from '@/lib/sfx-bus'
import useEditor from '@/store/use-editor'
import { PanelWrapper } from './panel-wrapper'
import { PanelSection } from '../controls/panel-section'
import { SliderControl } from '../controls/slider-control'
import { MetricControl } from '../controls/metric-control'
import { ToggleControl } from '../controls/toggle-control'
import { ActionButton, ActionGroup } from '../controls/action-button'
import { PresetsPopover } from './presets/presets-popover'
export function WindowPanel() {
const selectedIds = useViewer((s) => s.selection.selectedIds)
const setSelection = useViewer((s) => s.setSelection)
const nodes = useScene((s) => s.nodes)
const updateNode = useScene((s) => s.updateNode)
const deleteNode = useScene((s) => s.deleteNode)
const setMovingNode = useEditor((s) => s.setMovingNode)
const selectedId = selectedIds[0]
const node = selectedId
? (nodes[selectedId as AnyNode['id']] as WindowNode | undefined)
: undefined
const handleUpdate = useCallback(
(updates: Partial<WindowNode>) => {
if (!selectedId) return
updateNode(selectedId as AnyNode['id'], updates)
useScene.getState().dirtyNodes.add(selectedId as AnyNodeId)
},
[selectedId, updateNode],
)
const handleClose = useCallback(() => {
setSelection({ selectedIds: [] })
}, [setSelection])
const handleFlip = useCallback(() => {
if (!node) return
handleUpdate({
side: node.side === 'front' ? 'back' : 'front',
rotation: [node.rotation[0], node.rotation[1] + Math.PI, node.rotation[2]],
})
}, [node, handleUpdate])
const handleMove = useCallback(() => {
if (!node) return
sfxEmitter.emit('sfx:item-pick')
setMovingNode(node)
setSelection({ selectedIds: [] })
}, [node, setMovingNode, setSelection])
const handleDelete = useCallback(() => {
if (!selectedId || !node) return
sfxEmitter.emit('sfx:item-delete')
deleteNode(selectedId as AnyNode['id'])
if (node.parentId) useScene.getState().dirtyNodes.add(node.parentId as AnyNodeId)
setSelection({ selectedIds: [] })
}, [selectedId, node, deleteNode, setSelection])
const handleDuplicate = useCallback(() => {
if (!node || !node.parentId) return
sfxEmitter.emit('sfx:item-pick')
useScene.temporal.getState().pause()
const duplicate = WindowNode.parse({
position: [...node.position] as [number, number, number],
rotation: [...node.rotation] as [number, number, number],
side: node.side,
wallId: node.wallId,
parentId: node.parentId,
width: node.width,
height: node.height,
frameThickness: node.frameThickness,
frameDepth: node.frameDepth,
columnRatios: [...node.columnRatios],
rowRatios: [...node.rowRatios],
columnDividerThickness: node.columnDividerThickness,
rowDividerThickness: node.rowDividerThickness,
sill: node.sill,
sillDepth: node.sillDepth,
sillThickness: node.sillThickness,
metadata: { isNew: true },
})
useScene.getState().createNode(duplicate, node.parentId as AnyNodeId)
setMovingNode(duplicate)
setSelection({ selectedIds: [] })
}, [node, setMovingNode, setSelection])
const getWindowPresetData = useCallback(() => {
if (!node) return null
return {
width: node.width,
height: node.height,
frameThickness: node.frameThickness,
frameDepth: node.frameDepth,
columnRatios: node.columnRatios,
rowRatios: node.rowRatios,
columnDividerThickness: node.columnDividerThickness,
rowDividerThickness: node.rowDividerThickness,
sill: node.sill,
sillDepth: node.sillDepth,
sillThickness: node.sillThickness,
}
}, [node])
const handleSavePreset = useCallback(async (name: string) => {
const data = getWindowPresetData()
if (!data || !selectedId) return
const res = await fetch('/api/presets', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'window', name, data }),
})
if (res.ok) {
const json = await res.json()
const presetId = json.preset?.id
if (presetId) emitter.emit('preset:generate-thumbnail', { presetId, nodeId: selectedId })
}
}, [getWindowPresetData, selectedId])
const handleOverwritePreset = useCallback(async (id: string) => {
const data = getWindowPresetData()
if (!data || !selectedId) return
const res = await fetch(`/api/presets/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ data }),
})
if (res.ok) emitter.emit('preset:generate-thumbnail', { presetId: id, nodeId: selectedId })
}, [getWindowPresetData, selectedId])
const handleApplyPreset = useCallback((data: Record<string, unknown>) => {
handleUpdate(data as Partial<WindowNode>)
}, [handleUpdate])
if (!node || node.type !== 'window' || selectedIds.length !== 1) return null
const numCols = node.columnRatios.length
const numRows = node.rowRatios.length
const colSum = node.columnRatios.reduce((a, b) => a + b, 0)
const rowSum = node.rowRatios.reduce((a, b) => a + b, 0)
const normCols = node.columnRatios.map(r => r / colSum)
const normRows = node.rowRatios.map(r => r / rowSum)
const setColumnRatio = (index: number, newVal: number) => {
const clamped = Math.max(0.05, Math.min(0.95, newVal))
const neighborIdx = index < numCols - 1 ? index + 1 : index - 1
const delta = clamped - normCols[index]!
const neighborVal = Math.max(0.05, normCols[neighborIdx]! - delta)
const newRatios = normCols.map((v, i) => {
if (i === index) return clamped
if (i === neighborIdx) return neighborVal
return v
})
handleUpdate({ columnRatios: newRatios })
}
const setRowRatio = (index: number, newVal: number) => {
const clamped = Math.max(0.05, Math.min(0.95, newVal))
const neighborIdx = index < numRows - 1 ? index + 1 : index - 1
const delta = clamped - normRows[index]!
const neighborVal = Math.max(0.05, normRows[neighborIdx]! - delta)
const newRatios = normRows.map((v, i) => {
if (i === index) return clamped
if (i === neighborIdx) return neighborVal
return v
})
handleUpdate({ rowRatios: newRatios })
}
return (
<PanelWrapper
title={node.name || "Window"}
icon="/icons/window.png"
onClose={handleClose}
width={320}
>
{/* Presets strip */}
<div className="px-3 pt-2.5 pb-1.5 border-b border-border/30">
<PresetsPopover type="window" onApply={handleApplyPreset} onSave={handleSavePreset} onOverwrite={handleOverwritePreset}>
<button className="flex w-full items-center gap-2 rounded-lg border border-border/50 bg-[#2C2C2E] px-3 py-2 text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-[#3e3e3e] transition-colors">
<BookMarked className="h-3.5 w-3.5 shrink-0" />
<span>Presets</span>
</button>
</PresetsPopover>
</div>
<PanelSection title="Position">
<SliderControl
label={<>X<sub className="text-[11px] ml-[1px] opacity-70">pos</sub></>}
value={Math.round(node.position[0] * 100) / 100}
onChange={(v) => handleUpdate({ position: [v, node.position[1], node.position[2]] })}
min={-10}
max={10}
precision={2}
step={0.1}
unit="m"
/>
<SliderControl
label={<>Y<sub className="text-[11px] ml-[1px] opacity-70">pos</sub></>}
value={Math.round(node.position[1] * 100) / 100}
onChange={(v) => handleUpdate({ position: [node.position[0], v, node.position[2]] })}
min={-10}
max={10}
precision={2}
step={0.1}
unit="m"
/>
<div className="pt-2 pb-1 px-1">
<ActionButton
icon={<FlipHorizontal2 className="h-4 w-4" />}
label="Flip Side"
onClick={handleFlip}
className="w-full"
/>
</div>
</PanelSection>
<PanelSection title="Dimensions">
<SliderControl
label="Width"
value={Math.round(node.width * 100) / 100}
onChange={(v) => handleUpdate({ width: v })}
min={0.2}
max={5}
precision={2}
step={0.1}
unit="m"
/>
<SliderControl
label="Height"
value={Math.round(node.height * 100) / 100}
onChange={(v) => handleUpdate({ height: v })}
min={0.2}
max={5}
precision={2}
step={0.1}
unit="m"
/>
</PanelSection>
<PanelSection title="Frame">
<SliderControl
label="Thickness"
value={Math.round(node.frameThickness * 1000) / 1000}
onChange={(v) => handleUpdate({ frameThickness: v })}
min={0.01}
max={0.2}
precision={3}
step={0.01}
unit="m"
/>
<SliderControl
label="Depth"
value={Math.round(node.frameDepth * 1000) / 1000}
onChange={(v) => handleUpdate({ frameDepth: v })}
min={0.01}
max={0.3}
precision={3}
step={0.01}
unit="m"
/>
</PanelSection>
<PanelSection title="Grid">
<SliderControl
label="Columns"
value={numCols}
onChange={(v) => {
const n = Math.max(1, Math.min(8, Math.round(v)))
handleUpdate({ columnRatios: Array(n).fill(1 / n) })
}}
min={1}
max={8}
precision={0}
step={1}
/>
<SliderControl
label="Rows"
value={numRows}
onChange={(v) => {
const n = Math.max(1, Math.min(8, Math.round(v)))
handleUpdate({ rowRatios: Array(n).fill(1 / n) })
}}
min={1}
max={8}
precision={0}
step={1}
/>
{numCols > 1 && (
<div className="mt-2 flex flex-col gap-1">
<div className="mb-1 px-1 text-[10px] font-medium uppercase tracking-wider text-muted-foreground/80">Col Widths</div>
{normCols.map((ratio, i) => (
<SliderControl
key={`c-${i}`}
label={`C${i + 1}`}
value={Math.round(ratio * 100 * 10) / 10}
onChange={(v) => setColumnRatio(i, v / 100)}
min={5}
max={95}
precision={1}
step={1}
unit="%"
/>
))}
<div className="mt-1 border-t border-border/50 pt-1">
<SliderControl
label="Divider"
value={Math.round((node.columnDividerThickness ?? 0.03) * 1000) / 1000}
onChange={(v) => handleUpdate({ columnDividerThickness: v })}
min={0.005}
max={0.1}
precision={3}
step={0.01}
unit="m"
/>
</div>
</div>
)}
{numRows > 1 && (
<div className="mt-2 flex flex-col gap-1">
<div className="mb-1 px-1 text-[10px] font-medium uppercase tracking-wider text-muted-foreground/80">Row Heights</div>
{normRows.map((ratio, i) => (
<SliderControl
key={`r-${i}`}
label={`R${i + 1}`}
value={Math.round(ratio * 100 * 10) / 10}
onChange={(v) => setRowRatio(i, v / 100)}
min={5}
max={95}
precision={1}
step={1}
unit="%"
/>
))}
<div className="mt-1 border-t border-border/50 pt-1">
<SliderControl
label="Divider"
value={Math.round((node.rowDividerThickness ?? 0.03) * 1000) / 1000}
onChange={(v) => handleUpdate({ rowDividerThickness: v })}
min={0.005}
max={0.1}
precision={3}
step={0.01}
unit="m"
/>
</div>
</div>
)}
</PanelSection>
<PanelSection title="Sill">
<ToggleControl
label="Enable Sill"
checked={node.sill}
onChange={(checked) => handleUpdate({ sill: checked })}
/>
{node.sill && (
<div className="mt-1 flex flex-col gap-1">
<SliderControl
label="Depth"
value={Math.round(node.sillDepth * 1000) / 1000}
onChange={(v) => handleUpdate({ sillDepth: v })}
min={0.01}
max={0.5}
precision={3}
step={0.01}
unit="m"
/>
<SliderControl
label="Thickness"
value={Math.round(node.sillThickness * 1000) / 1000}
onChange={(v) => handleUpdate({ sillThickness: v })}
min={0.005}
max={0.2}
precision={3}
step={0.01}
unit="m"
/>
</div>
)}
</PanelSection>
<PanelSection title="Actions">
<ActionGroup>
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
<ActionButton icon={<Copy className="h-3.5 w-3.5" />} label="Duplicate" onClick={handleDuplicate} />
<ActionButton
icon={<Trash2 className="h-3.5 w-3.5 text-red-400" />}
label="Delete"
onClick={handleDelete}
className="hover:bg-red-500/20"
/>
</ActionGroup>
</PanelSection>
</PanelWrapper>
)
}

Some files were not shown because too many files have changed in this diff Show More