Merge branch 'main' into feat/elevator-system

# Conflicts:
#	packages/editor/src/components/tools/item/move-tool.tsx
#	packages/editor/src/components/tools/tool-manager.tsx
#	packages/editor/src/components/ui/panels/panel-manager.tsx
#	packages/editor/src/store/use-editor.tsx
#	packages/viewer/src/components/renderers/site/site-renderer.tsx
#	packages/viewer/src/components/viewer/ground-occluder.tsx
#	packages/viewer/src/components/viewer/index.tsx
#	packages/viewer/src/components/viewer/post-processing.tsx
This commit is contained in:
sudhir
2026-05-13 01:38:55 +05:30
256 changed files with 9027 additions and 5479 deletions
+1 -1
View File
@@ -173,7 +173,7 @@ function parseIfMatch(raw: string | null): number | undefined {
const inner = match ? match[1] : trimmed
if (!inner) return undefined
const n = Number(inner)
if (!Number.isFinite(n) || !Number.isInteger(n) || n < 0) return undefined
if (!(Number.isFinite(n) && Number.isInteger(n)) || n < 0) return undefined
return n
}
+1
View File
@@ -1,5 +1,6 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "../../../styles/elevation.css";
@source "../../../packages/editor/src";
@custom-variant dark (&:is(.dark *));
+26 -5
View File
@@ -1,13 +1,34 @@
'use client'
import { Editor, type SidebarTab, ViewerToolbarLeft, ViewerToolbarRight } from '@pascal-app/editor'
import { Editor, ItemsPanel } from '@pascal-app/editor'
import { Layers, Package, Settings } from 'lucide-react'
import Link from 'next/link'
import {
CommunityViewerToolbarLeft,
CommunityViewerToolbarRight,
} from '@/components/viewer-toolbar'
const SIDEBAR_TABS: (SidebarTab & { component: React.ComponentType })[] = [
const SIDEBAR_TABS = [
{
id: 'site',
label: 'Scene',
component: () => null, // Built-in SitePanel handles this
component: () => null,
mobileDefaultSnap: 0.5,
mobileIcon: <Layers className="h-5 w-5" />,
},
{
id: 'items',
label: 'Items',
component: ItemsPanel,
mobileDefaultSnap: 0.5,
mobileIcon: <Package className="h-5 w-5" />,
},
{
id: 'settings',
label: 'Settings',
component: () => null,
mobileDefaultSnap: 0.5,
mobileIcon: <Settings className="h-5 w-5" />,
},
]
@@ -36,8 +57,8 @@ export default function Home() {
layoutVersion="v2"
projectId={PROJECT_ID}
sidebarTabs={SIDEBAR_TABS}
viewerToolbarLeft={<ViewerToolbarLeft />}
viewerToolbarRight={<ViewerToolbarRight />}
viewerToolbarLeft={<CommunityViewerToolbarLeft />}
viewerToolbarRight={<CommunityViewerToolbarRight />}
/>
</div>
)
+3 -4
View File
@@ -5,12 +5,11 @@ import {
Editor,
type SceneGraph,
type SidebarTab,
ViewerToolbarLeft,
ViewerToolbarRight,
} from '@pascal-app/editor'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { useCallback, useEffect, useRef, useState } from 'react'
import { CommunityViewerToolbarLeft, CommunityViewerToolbarRight } from './viewer-toolbar'
export interface SceneMeta {
id: string
@@ -200,8 +199,8 @@ export function SceneLoader({ initialScene, meta }: SceneLoaderProps) {
onThumbnailCapture={handleThumb}
projectId={meta.projectId ?? 'default'}
sidebarTabs={SIDEBAR_TABS}
viewerToolbarLeft={<ViewerToolbarLeft />}
viewerToolbarRight={<ViewerToolbarRight />}
viewerToolbarLeft={<CommunityViewerToolbarLeft />}
viewerToolbarRight={<CommunityViewerToolbarRight />}
/>
</div>
)
@@ -0,0 +1,49 @@
'use client'
import * as TooltipPrimitive from '@radix-ui/react-tooltip'
import type * as React from 'react'
import { cn } from '@/lib/utils'
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return <TooltipPrimitive.Provider delayDuration={delayDuration} {...props} />
}
function Tooltip({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return (
<TooltipProvider>
<TooltipPrimitive.Root {...props} />
</TooltipProvider>
)
}
function TooltipTrigger({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger {...props} />
}
function TooltipContent({
className,
sideOffset = 6,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
className={cn(
'fade-in-0 zoom-in-95 data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) animate-in rounded-md bg-foreground px-3 py-1.5 text-background text-xs data-[state=closed]:animate-out',
className,
)}
sideOffset={sideOffset}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }
+362
View File
@@ -0,0 +1,362 @@
'use client'
import { Icon as IconifyIcon } from '@iconify/react'
import { useEditor, useSidebarStore, type ViewMode } from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import {
ChevronsLeft,
ChevronsRight,
Columns2,
Eye,
EyeOff,
Footprints,
Grid2X2,
Moon,
Sun,
} from 'lucide-react'
import Image from 'next/image'
import { type ReactNode, useCallback } from 'react'
import { cn } from '@/lib/utils'
import { Tooltip, TooltipContent, TooltipTrigger } from './toolbar-tooltip'
const TOOLBAR_CONTAINER =
'inline-flex h-8 items-stretch overflow-hidden rounded-xl border border-border bg-background/90 shadow-2xl backdrop-blur-md'
const TOOLBAR_BTN =
'flex w-8 items-center justify-center text-muted-foreground/80 transition-colors hover:bg-white/8 hover:text-foreground/90'
function ToolbarTooltip({ children, label }: { children: ReactNode; label: string }) {
return (
<Tooltip>
<TooltipTrigger asChild>{children}</TooltipTrigger>
<TooltipContent side="bottom">{label}</TooltipContent>
</Tooltip>
)
}
const VIEW_MODES: { id: ViewMode; label: string; icon: React.ReactNode }[] = [
{
id: '3d',
label: '3D',
icon: (
<Image
alt=""
className="h-3.5 w-3.5 object-contain"
height={14}
src="/icons/building.png"
width={14}
/>
),
},
{
id: '2d',
label: '2D',
icon: (
<Image
alt=""
className="h-3.5 w-3.5 object-contain"
height={14}
src="/icons/blueprint.png"
width={14}
/>
),
},
{
id: 'split',
label: 'Split',
icon: <Columns2 className="h-3 w-3" />,
},
]
const levelModeOrder = ['stacked', 'exploded', 'solo'] as const
const levelModeLabels: Record<string, string> = {
manual: 'Stack',
stacked: 'Stack',
exploded: 'Exploded',
solo: 'Solo',
}
const wallModeOrder = ['cutaway', 'up', 'down'] as const
const wallModeConfig: Record<string, { icon: string; label: string }> = {
up: { icon: '/icons/room.png', label: 'Full height' },
cutaway: { icon: '/icons/wallcut.png', label: 'Cutaway' },
down: { icon: '/icons/walllow.png', label: 'Low' },
}
function ViewModeControl() {
const viewMode = useEditor((state) => state.viewMode)
const setViewMode = useEditor((state) => state.setViewMode)
return (
<div className={TOOLBAR_CONTAINER}>
{VIEW_MODES.map((mode) => {
const isActive = viewMode === mode.id
return (
<ToolbarTooltip key={mode.id} label={mode.label}>
<button
aria-label={mode.label}
aria-pressed={isActive}
className={cn(
'flex items-center justify-center gap-1.5 px-2.5 font-medium text-xs transition-colors',
isActive
? 'bg-white/10 text-foreground'
: 'text-muted-foreground/70 hover:bg-white/8 hover:text-muted-foreground',
)}
onClick={() => setViewMode(mode.id)}
type="button"
>
{mode.icon}
<span>{mode.label}</span>
</button>
</ToolbarTooltip>
)
})}
</div>
)
}
function CollapseSidebarButton() {
const isCollapsed = useSidebarStore((state) => state.isCollapsed)
const setIsCollapsed = useSidebarStore((state) => state.setIsCollapsed)
const toggle = useCallback(() => {
setIsCollapsed(!isCollapsed)
}, [isCollapsed, setIsCollapsed])
return (
<div className={TOOLBAR_CONTAINER}>
<ToolbarTooltip label={isCollapsed ? 'Expand sidebar' : 'Collapse sidebar'}>
<button
aria-label={isCollapsed ? 'Expand sidebar' : 'Collapse sidebar'}
className={TOOLBAR_BTN}
onClick={toggle}
type="button"
>
{isCollapsed ? (
<ChevronsRight className="h-4 w-4" />
) : (
<ChevronsLeft className="h-4 w-4" />
)}
</button>
</ToolbarTooltip>
</div>
)
}
function LevelModeToggle() {
const levelMode = useViewer((state) => state.levelMode)
const setLevelMode = useViewer((state) => state.setLevelMode)
const isDefault = levelMode === 'stacked' || levelMode === 'manual'
const cycle = () => {
if (levelMode === 'manual') {
setLevelMode('stacked')
return
}
const index = levelModeOrder.indexOf(levelMode as (typeof levelModeOrder)[number])
const next = levelModeOrder[(index + 1) % levelModeOrder.length]
if (next) setLevelMode(next)
}
const label = `Levels: ${levelMode === 'manual' ? 'Manual' : (levelModeLabels[levelMode] ?? 'Stack')}`
return (
<ToolbarTooltip label={label}>
<button
className={cn(
TOOLBAR_BTN,
'w-auto gap-1.5 px-2.5',
!isDefault && 'bg-white/10 text-foreground/90',
)}
onClick={cycle}
type="button"
>
{levelMode === 'solo' ? (
<IconifyIcon height={14} icon="lucide:diamond" width={14} />
) : levelMode === 'exploded' ? (
<IconifyIcon height={14} icon="charm:stack-pop" width={14} />
) : (
<IconifyIcon height={14} icon="charm:stack-push" width={14} />
)}
<span className="font-medium text-xs">{levelModeLabels[levelMode] ?? 'Stack'}</span>
</button>
</ToolbarTooltip>
)
}
function WallModeToggle() {
const wallMode = useViewer((state) => state.wallMode)
const setWallMode = useViewer((state) => state.setWallMode)
const config = wallModeConfig[wallMode] ?? wallModeConfig.cutaway!
const cycle = () => {
const index = wallModeOrder.indexOf(wallMode as (typeof wallModeOrder)[number])
const next = wallModeOrder[(index + 1) % wallModeOrder.length]
if (next) setWallMode(next)
}
return (
<ToolbarTooltip label={`Walls: ${config.label}`}>
<button
className={cn(
TOOLBAR_BTN,
'w-auto gap-1.5 px-2.5',
wallMode !== 'cutaway'
? 'bg-white/10'
: 'opacity-60 grayscale hover:opacity-100 hover:grayscale-0',
)}
onClick={cycle}
type="button"
>
<Image alt="" className="h-4 w-4 object-contain" height={16} src={config.icon} width={16} />
<span className="font-medium text-xs">{config.label}</span>
</button>
</ToolbarTooltip>
)
}
function GridVisibilityToggle() {
const showGrid = useViewer((state) => state.showGrid)
const setShowGrid = useViewer((state) => state.setShowGrid)
return (
<ToolbarTooltip label={`Grid: ${showGrid ? 'Visible' : 'Hidden'}`}>
<button
aria-label={`Grid: ${showGrid ? 'Visible' : 'Hidden'}`}
aria-pressed={showGrid}
className={cn(
TOOLBAR_BTN,
'w-auto gap-1.5 px-2.5',
showGrid
? 'bg-white/10 text-foreground/90'
: 'opacity-60 grayscale hover:opacity-100 hover:grayscale-0',
)}
onClick={() => setShowGrid(!showGrid)}
type="button"
>
<Grid2X2 className="h-3.5 w-3.5" />
{showGrid ? <Eye className="h-3.5 w-3.5" /> : <EyeOff className="h-3.5 w-3.5" />}
</button>
</ToolbarTooltip>
)
}
function UnitToggle() {
const unit = useViewer((state) => state.unit)
const setUnit = useViewer((state) => state.setUnit)
return (
<ToolbarTooltip label={unit === 'metric' ? 'Metric (m)' : 'Imperial (ft)'}>
<button
className={TOOLBAR_BTN}
onClick={() => setUnit(unit === 'metric' ? 'imperial' : 'metric')}
type="button"
>
<span className="font-semibold text-[10px]">{unit === 'metric' ? 'm' : 'ft'}</span>
</button>
</ToolbarTooltip>
)
}
function ThemeToggle() {
const theme = useViewer((state) => state.theme)
const setTheme = useViewer((state) => state.setTheme)
return (
<ToolbarTooltip label={theme === 'dark' ? 'Dark' : 'Light'}>
<button
className={cn(TOOLBAR_BTN, theme === 'dark' ? 'text-indigo-400/70' : 'text-amber-400/70')}
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
type="button"
>
{theme === 'dark' ? <Moon className="h-3.5 w-3.5" /> : <Sun className="h-3.5 w-3.5" />}
</button>
</ToolbarTooltip>
)
}
function CameraModeToggle() {
const cameraMode = useViewer((state) => state.cameraMode)
const setCameraMode = useViewer((state) => state.setCameraMode)
return (
<ToolbarTooltip label={cameraMode === 'perspective' ? 'Perspective' : 'Orthographic'}>
<button
className={cn(
TOOLBAR_BTN,
cameraMode === 'orthographic' && 'bg-white/10 text-foreground/90',
)}
onClick={() => setCameraMode(cameraMode === 'perspective' ? 'orthographic' : 'perspective')}
type="button"
>
{cameraMode === 'perspective' ? (
<IconifyIcon height={16} icon="icon-park-outline:perspective" width={16} />
) : (
<IconifyIcon height={16} icon="vaadin:grid" width={16} />
)}
</button>
</ToolbarTooltip>
)
}
function WalkthroughButton() {
const isFirstPersonMode = useEditor((state) => state.isFirstPersonMode)
const setFirstPersonMode = useEditor((state) => state.setFirstPersonMode)
return (
<ToolbarTooltip label="Walkthrough">
<button
className={cn(
TOOLBAR_BTN,
isFirstPersonMode && 'bg-emerald-500/15 text-emerald-400 hover:bg-emerald-500/20',
)}
onClick={() => setFirstPersonMode(!isFirstPersonMode)}
type="button"
>
<Footprints className="h-4 w-4" />
</button>
</ToolbarTooltip>
)
}
function PreviewButton() {
return (
<ToolbarTooltip label="Preview mode">
<button
className="flex items-center gap-1.5 px-2.5 font-medium text-muted-foreground/80 text-xs transition-colors hover:bg-white/8 hover:text-foreground/90"
onClick={() => useEditor.getState().setPreviewMode(true)}
type="button"
>
<Eye className="h-3.5 w-3.5 shrink-0" />
<span>Preview</span>
</button>
</ToolbarTooltip>
)
}
export function CommunityViewerToolbarLeft() {
return (
<>
<CollapseSidebarButton />
<ViewModeControl />
</>
)
}
export function CommunityViewerToolbarRight() {
return (
<div className={TOOLBAR_CONTAINER}>
<LevelModeToggle />
<WallModeToggle />
<GridVisibilityToggle />
<div className="my-1.5 w-px bg-border/50" />
<UnitToggle />
<ThemeToggle />
<CameraModeToggle />
<div className="my-1.5 w-px bg-border/50" />
<WalkthroughButton />
<PreviewButton />
</div>
)
}
+6 -27
View File
@@ -1,8 +1,8 @@
/**
* Environment variable validation for the editor app.
*
* This file validates that required environment variables are set at runtime.
* Variables are defined in the root .env file.
* This file validates environment variables used by the standalone editor app.
* Values are loaded from the repo root .env.local by package scripts.
*
* @see https://env.t3.gg/docs/nextjs
*/
@@ -13,42 +13,21 @@ export const env = createEnv({
/**
* Server-side environment variables (not exposed to client)
*/
server: {
// Database
POSTGRES_URL: z.string().min(1),
SUPABASE_SERVICE_ROLE_KEY: z.string().min(1),
// Auth
BETTER_AUTH_SECRET: z.string().min(1),
GOOGLE_CLIENT_ID: z.string().optional(),
GOOGLE_CLIENT_SECRET: z.string().optional(),
// Email
RESEND_API_KEY: z.string().optional(),
},
server: {},
/**
* Client-side environment variables (exposed to browser via NEXT_PUBLIC_)
*/
client: {
NEXT_PUBLIC_SUPABASE_URL: z.string().min(1),
NEXT_PUBLIC_SUPABASE_ANON_KEY: z.string().optional(),
NEXT_PUBLIC_ASSETS_CDN_URL: z.string().optional(),
},
/**
* Runtime values - pulls from process.env
*/
runtimeEnv: {
// Server
POSTGRES_URL: process.env.POSTGRES_URL,
SUPABASE_SERVICE_ROLE_KEY: process.env.SUPABASE_SERVICE_ROLE_KEY,
BETTER_AUTH_SECRET: process.env.BETTER_AUTH_SECRET,
GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID,
GOOGLE_CLIENT_SECRET: process.env.GOOGLE_CLIENT_SECRET,
RESEND_API_KEY: process.env.RESEND_API_KEY,
// Client
NEXT_PUBLIC_SUPABASE_URL: process.env.NEXT_PUBLIC_SUPABASE_URL,
NEXT_PUBLIC_SUPABASE_ANON_KEY: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY,
NEXT_PUBLIC_ASSETS_CDN_URL:
process.env.NEXT_PUBLIC_ASSETS_CDN_URL ?? process.env.NEXT_PUBLIC_EDITOR_ASSETS_CDN_URL,
},
/**
+3
View File
@@ -1,6 +1,9 @@
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
logging: {
browserToTerminal: true,
},
typescript: {
ignoreBuildErrors: true,
},
+7 -4
View File
@@ -4,23 +4,26 @@
"type": "module",
"private": true,
"scripts": {
"dev": "dotenv -e ./.env.local --override -- next dev --port 3002",
"build": "dotenv -e ./.env.local --override -- next build",
"dev": "dotenv -e ../../.env.local -- next dev --port 3002",
"build": "dotenv -e ../../.env.local -- next build",
"start": "next start",
"lint": "biome lint",
"check-types": "next typegen && tsc --noEmit"
},
"dependencies": {
"@iconify/react": "^6.0.2",
"@number-flow/react": "^0.5.14",
"@pascal-app/core": "*",
"@pascal-app/editor": "*",
"@pascal-app/mcp": "*",
"@pascal-app/viewer": "*",
"@radix-ui/react-tooltip": "^1.2.8",
"@react-three/drei": "^10.7.7",
"@react-three/fiber": "^9.5.0",
"@tailwindcss/postcss": "^4.2.1",
"clsx": "^2.1.1",
"geist": "^1.7.0",
"lucide-react": "^1.7.0",
"next": "16.2.1",
"postcss": "^8.5.6",
"react": "^19.2.4",
@@ -37,9 +40,9 @@
"@types/react": "19.2.2",
"@types/react-dom": "19.2.2",
"agentation": "^2.3.2",
"react-grab": "^0.1.25",
"react-grab": "^0.1.29",
"react-scan": "^0.5.3",
"tw-animate-css": "^1.4.0",
"typescript": "5.9.3"
"typescript": "6.0.2"
}
}
Binary file not shown.
+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="4"/>
<path d="M12 2v2M12 20v2M2 12h2M20 12h2"/>
<path d="M12 8v1M12 15v1M8 12h1M15 12h1"/>
</svg>

After

Width:  |  Height:  |  Size: 290 B

+9
View File
@@ -0,0 +1,9 @@
{
"$schema": "https://openapi.vercel.sh/vercel.json",
"buildCommand": "cd ../.. && npx -y bun@1.3.13 run build --filter=editor",
"installCommand": "cd ../.. && npx -y bun@1.3.13 install --frozen-lockfile",
"outputDirectory": ".next",
"cleanUrls": true,
"trailingSlash": false,
"bunVersion": "1.x"
}