Merge pull request #302 from sudhir9297/fix/fri-8-may

fix: Add procedural column variants, editor copy/paste, and wall junction planning improvements
This commit is contained in:
Wassim SAMAD
2026-05-12 11:11:07 -04:00
committed by GitHub
49 changed files with 4392 additions and 809 deletions
+7 -9
View File
@@ -1,14 +1,12 @@
'use client' 'use client'
import { import { Editor, ItemsPanel } from '@pascal-app/editor'
Editor,
ItemsPanel,
type SidebarTab,
ViewerToolbarLeft,
ViewerToolbarRight,
} from '@pascal-app/editor'
import { Layers, Package, Settings } from 'lucide-react' import { Layers, Package, Settings } from 'lucide-react'
import Link from 'next/link' import Link from 'next/link'
import {
CommunityViewerToolbarLeft,
CommunityViewerToolbarRight,
} from '@/components/viewer-toolbar'
const SIDEBAR_TABS = [ const SIDEBAR_TABS = [
{ {
@@ -59,8 +57,8 @@ export default function Home() {
layoutVersion="v2" layoutVersion="v2"
projectId={PROJECT_ID} projectId={PROJECT_ID}
sidebarTabs={SIDEBAR_TABS} sidebarTabs={SIDEBAR_TABS}
viewerToolbarLeft={<ViewerToolbarLeft />} viewerToolbarLeft={<CommunityViewerToolbarLeft />}
viewerToolbarRight={<ViewerToolbarRight />} viewerToolbarRight={<CommunityViewerToolbarRight />}
/> />
</div> </div>
) )
+3 -4
View File
@@ -5,12 +5,11 @@ import {
Editor, Editor,
type SceneGraph, type SceneGraph,
type SidebarTab, type SidebarTab,
ViewerToolbarLeft,
ViewerToolbarRight,
} from '@pascal-app/editor' } from '@pascal-app/editor'
import Link from 'next/link' import Link from 'next/link'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import { CommunityViewerToolbarLeft, CommunityViewerToolbarRight } from './viewer-toolbar'
export interface SceneMeta { export interface SceneMeta {
id: string id: string
@@ -200,8 +199,8 @@ export function SceneLoader({ initialScene, meta }: SceneLoaderProps) {
onThumbnailCapture={handleThumb} onThumbnailCapture={handleThumb}
projectId={meta.projectId ?? 'default'} projectId={meta.projectId ?? 'default'}
sidebarTabs={SIDEBAR_TABS} sidebarTabs={SIDEBAR_TABS}
viewerToolbarLeft={<ViewerToolbarLeft />} viewerToolbarLeft={<CommunityViewerToolbarLeft />}
viewerToolbarRight={<ViewerToolbarRight />} viewerToolbarRight={<CommunityViewerToolbarRight />}
/> />
</div> </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>
)
}
+2
View File
@@ -11,11 +11,13 @@
"check-types": "next typegen && tsc --noEmit" "check-types": "next typegen && tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"@iconify/react": "^6.0.2",
"@number-flow/react": "^0.5.14", "@number-flow/react": "^0.5.14",
"@pascal-app/core": "*", "@pascal-app/core": "*",
"@pascal-app/editor": "*", "@pascal-app/editor": "*",
"@pascal-app/mcp": "*", "@pascal-app/mcp": "*",
"@pascal-app/viewer": "*", "@pascal-app/viewer": "*",
"@radix-ui/react-tooltip": "^1.2.8",
"@react-three/drei": "^10.7.7", "@react-three/drei": "^10.7.7",
"@react-three/fiber": "^9.5.0", "@react-three/fiber": "^9.5.0",
"@tailwindcss/postcss": "^4.2.1", "@tailwindcss/postcss": "^4.2.1",
+2 -7
View File
@@ -26,11 +26,13 @@
"name": "editor", "name": "editor",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@iconify/react": "^6.0.2",
"@number-flow/react": "^0.5.14", "@number-flow/react": "^0.5.14",
"@pascal-app/core": "*", "@pascal-app/core": "*",
"@pascal-app/editor": "*", "@pascal-app/editor": "*",
"@pascal-app/mcp": "*", "@pascal-app/mcp": "*",
"@pascal-app/viewer": "*", "@pascal-app/viewer": "*",
"@radix-ui/react-tooltip": "^1.2.8",
"@react-three/drei": "^10.7.7", "@react-three/drei": "^10.7.7",
"@react-three/fiber": "^9.5.0", "@react-three/fiber": "^9.5.0",
"@tailwindcss/postcss": "^4.2.1", "@tailwindcss/postcss": "^4.2.1",
@@ -205,7 +207,6 @@
"name": "@pascal-app/viewer", "name": "@pascal-app/viewer",
"version": "0.8.0", "version": "0.8.0",
"dependencies": { "dependencies": {
"polygon-clipping": "^0.15.7",
"three-bvh-csg": "^0.0.18", "three-bvh-csg": "^0.0.18",
"three-mesh-bvh": "^0.9.8", "three-mesh-bvh": "^0.9.8",
"zustand": "^5", "zustand": "^5",
@@ -1260,8 +1261,6 @@
"pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="], "pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="],
"polygon-clipping": ["polygon-clipping@0.15.7", "", { "dependencies": { "robust-predicates": "^3.0.2", "splaytree": "^3.1.0" } }, "sha512-nhfdr83ECBg6xtqOAJab1tbksbBAOMUltN60bU+llHVOL0e5Onm1WpAXXWXVB39L8AJFssoIhEVuy/S90MmotA=="],
"possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="], "possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="],
"postcss": ["postcss@8.5.14", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg=="], "postcss": ["postcss@8.5.14", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg=="],
@@ -1322,8 +1321,6 @@
"reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="],
"robust-predicates": ["robust-predicates@3.0.3", "", {}, "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA=="],
"router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="],
"run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="],
@@ -1374,8 +1371,6 @@
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"splaytree": ["splaytree@3.2.3", "", {}, "sha512-7OXrNWzy6CK+r7Ch9OLPBDTKfB6XlWHjX4P0RU5B3IgFuWPeYN0XtRtlexGRjgbQxpfaUve6jTAwBGWuGntz/w=="],
"stats-gl": ["stats-gl@2.4.2", "", { "dependencies": { "@types/three": "*", "three": "^0.170.0" } }, "sha512-g5O9B0hm9CvnM36+v7SFl39T7hmAlv541tU81ME8YeSb3i1CIP5/QdDeSB3A0la0bKNHpxpwxOVRo2wFTYEosQ=="], "stats-gl": ["stats-gl@2.4.2", "", { "dependencies": { "@types/three": "*", "three": "^0.170.0" } }, "sha512-g5O9B0hm9CvnM36+v7SFl39T7hmAlv541tU81ME8YeSb3i1CIP5/QdDeSB3A0la0bKNHpxpwxOVRo2wFTYEosQ=="],
"stats.js": ["stats.js@0.17.0", "", {}, "sha512-hNKz8phvYLPEcRkeG1rsGmV5ChMjKDAWU7/OJJdDErPBNChQXxCo3WZurGpnWc6gZhAzEPFad1aVgyOANH1sMw=="], "stats.js": ["stats.js@0.17.0", "", {}, "sha512-hNKz8phvYLPEcRkeG1rsGmV5ChMjKDAWU7/OJJdDErPBNChQXxCo3WZurGpnWc6gZhAzEPFad1aVgyOANH1sMw=="],
+11
View File
@@ -45,6 +45,8 @@ export { getRenderableSlabPolygon } from './lib/slab-polygon'
export { export {
detectSpacesForLevel, detectSpacesForLevel,
initSpaceDetectionSync, initSpaceDetectionSync,
planAutoSlabsForLevel,
type AutoSlabSyncPlan,
type Space, type Space,
wallTouchesOthers, wallTouchesOthers,
} from './lib/space-detection' } from './lib/space-detection'
@@ -107,6 +109,15 @@ export {
type WallMiterBoundaryPoints, type WallMiterBoundaryPoints,
type WallMiterData, type WallMiterData,
} from './systems/wall/wall-mitering' } from './systems/wall/wall-mitering'
export {
constrainWallMoveDeltaToAxis,
getPerpendicularWallMoveAxis,
planWallMoveJunctions,
type WallMoveBridgePlan,
type WallMoveAxis,
type WallMoveJunctionPlan,
type WallPlanPoint,
} from './systems/wall/wall-move'
export type { SceneGraph } from './utils/clone-scene-graph' export type { SceneGraph } from './utils/clone-scene-graph'
export { cloneLevelSubtree, cloneSceneGraph, forkSceneGraph } from './utils/clone-scene-graph' export { cloneLevelSubtree, cloneSceneGraph, forkSceneGraph } from './utils/clone-scene-graph'
export { isObject } from './utils/types' export { isObject } from './utils/types'
+29 -10
View File
@@ -41,6 +41,12 @@ type DetectedRoom = {
bbox: ReturnType<typeof bboxOf> bbox: ReturnType<typeof bboxOf>
} }
export type AutoSlabSyncPlan = {
create: SlabNodeType[]
update: Array<{ id: SlabNodeType['id']; data: Partial<SlabNodeType> }>
delete: Array<SlabNodeType['id']>
}
const DEFAULT_AUTO_SLAB_ELEVATION = 0.05 const DEFAULT_AUTO_SLAB_ELEVATION = 0.05
const DEFAULT_AUTO_CEILING_HEIGHT = 2.5 const DEFAULT_AUTO_CEILING_HEIGHT = 2.5
const ROOM_CURVE_TOLERANCE = 0.04 const ROOM_CURVE_TOLERANCE = 0.04
@@ -488,12 +494,10 @@ function buildSpace(levelId: string, polygon: Point2D[]): Space {
} }
} }
function syncAutoSlabsForLevel( export function planAutoSlabsForLevel(
levelId: string,
roomPolygons: Point2D[][], roomPolygons: Point2D[][],
existingSlabs: SlabNodeType[], existingSlabs: SlabNodeType[],
sceneStore: any, ): AutoSlabSyncPlan {
) {
const manualSlabs = existingSlabs.filter((slab) => !slab.autoFromWalls) const manualSlabs = existingSlabs.filter((slab) => !slab.autoFromWalls)
const manualSignatures = new Set( const manualSignatures = new Set(
manualSlabs.map((slab) => polygonSignature(slab.polygon.map(pointFromTuple))), manualSlabs.map((slab) => polygonSignature(slab.polygon.map(pointFromTuple))),
@@ -618,16 +622,31 @@ function syncAutoSlabsForLevel(
) )
} }
if (slabsToDelete.length > 0) { return {
sceneStore.getState().deleteNodes(slabsToDelete) create: slabsToCreate,
update: slabsToUpdate,
delete: slabsToDelete,
}
} }
if (slabsToUpdate.length > 0) { function syncAutoSlabsForLevel(
sceneStore.getState().updateNodes(slabsToUpdate) levelId: string,
roomPolygons: Point2D[][],
existingSlabs: SlabNodeType[],
sceneStore: any,
) {
const plan = planAutoSlabsForLevel(roomPolygons, existingSlabs)
if (plan.delete.length > 0) {
sceneStore.getState().deleteNodes(plan.delete)
} }
if (slabsToCreate.length > 0) { if (plan.update.length > 0) {
sceneStore.getState().createNodes(slabsToCreate.map((node) => ({ node, parentId: levelId }))) sceneStore.getState().updateNodes(plan.update)
}
if (plan.create.length > 0) {
sceneStore.getState().createNodes(plan.create.map((node) => ({ node, parentId: levelId })))
} }
} }
+1
View File
@@ -39,6 +39,7 @@ export {
ColumnShaftDetail, ColumnShaftDetail,
ColumnShaftProfile, ColumnShaftProfile,
ColumnStyle, ColumnStyle,
ColumnSupportStyle,
} from './nodes/column' } from './nodes/column'
export { DoorNode, DoorSegment } from './nodes/door' export { DoorNode, DoorSegment } from './nodes/door'
export { FenceBaseStyle, FenceNode, FenceStyle } from './nodes/fence' export { FenceBaseStyle, FenceNode, FenceStyle } from './nodes/fence'
+588 -6
View File
@@ -56,6 +56,20 @@ export const ColumnRingPlacement = z.enum(['ends', 'even', 'top', 'bottom'])
export const ColumnCarvingPlacement = z.enum(['shaft', 'base', 'capital', 'all']) export const ColumnCarvingPlacement = z.enum(['shaft', 'base', 'capital', 'all'])
export const ColumnSupportStyle = z.enum([
'vertical',
'a-frame',
'y-frame',
'v-frame',
'x-brace',
'k-brace',
'single-strut',
'tripod',
'trestle',
'portal-frame',
'box-frame',
])
export type ColumnStyle = z.infer<typeof ColumnStyle> export type ColumnStyle = z.infer<typeof ColumnStyle>
export type ColumnCrossSection = z.infer<typeof ColumnCrossSection> export type ColumnCrossSection = z.infer<typeof ColumnCrossSection>
export type ColumnShaftProfile = z.infer<typeof ColumnShaftProfile> export type ColumnShaftProfile = z.infer<typeof ColumnShaftProfile>
@@ -65,6 +79,7 @@ export type ColumnBaseStyle = z.infer<typeof ColumnBaseStyle>
export type ColumnCapitalStyle = z.infer<typeof ColumnCapitalStyle> export type ColumnCapitalStyle = z.infer<typeof ColumnCapitalStyle>
export type ColumnRingPlacement = z.infer<typeof ColumnRingPlacement> export type ColumnRingPlacement = z.infer<typeof ColumnRingPlacement>
export type ColumnCarvingPlacement = z.infer<typeof ColumnCarvingPlacement> export type ColumnCarvingPlacement = z.infer<typeof ColumnCarvingPlacement>
export type ColumnSupportStyle = z.infer<typeof ColumnSupportStyle>
export const ColumnNode = BaseNode.extend({ export const ColumnNode = BaseNode.extend({
id: objectId('column'), id: objectId('column'),
@@ -73,7 +88,7 @@ export const ColumnNode = BaseNode.extend({
rotation: z.number().default(0), rotation: z.number().default(0),
style: ColumnStyle.default('plain'), style: ColumnStyle.default('plain'),
crossSection: ColumnCrossSection.default('round'), crossSection: ColumnCrossSection.default('round'),
height: z.number().positive().default(2.8), height: z.number().positive().default(2.5),
radius: z.number().positive().default(0.22), radius: z.number().positive().default(0.22),
width: z.number().positive().default(0.44), width: z.number().positive().default(0.44),
depth: z.number().positive().default(0.44), depth: z.number().positive().default(0.44),
@@ -136,6 +151,12 @@ export const ColumnNode = BaseNode.extend({
lowerBandCarvingLevel: z.number().int().min(0).max(4).default(0), lowerBandCarvingLevel: z.number().int().min(0).max(4).default(0),
dentilCount: z.number().int().min(0).max(48).default(0), dentilCount: z.number().int().min(0).max(48).default(0),
beadCount: z.number().int().min(0).max(64).default(0), beadCount: z.number().int().min(0).max(64).default(0),
supportStyle: ColumnSupportStyle.default('vertical'),
braceWidth: z.number().positive().default(0.16),
braceDepth: z.number().positive().default(0.16),
braceBottomSpread: z.number().min(0.2).default(1.2),
braceTopSpread: z.number().min(0).default(0.12),
bracePlateEnabled: z.boolean().default(true),
material: MaterialSchema.optional(), material: MaterialSchema.optional(),
materialPreset: z.string().optional(), materialPreset: z.string().optional(),
}).describe(dedent` }).describe(dedent`
@@ -150,6 +171,7 @@ export const ColumnNode = BaseNode.extend({
- baseStyle/capitalStyle: procedural base and top treatment with tier/detail controls - baseStyle/capitalStyle: procedural base and top treatment with tier/detail controls
- baseHeight/capitalHeight: bottom and top block proportions - baseHeight/capitalHeight: bottom and top block proportions
- ring/flute/spiral/panel/lathe/carving fields: procedural detail controls - ring/flute/spiral/panel/lathe/carving fields: procedural detail controls
- supportStyle/brace fields: vertical column or procedural support assembly
`) `)
export const COLUMN_PRESETS = { export const COLUMN_PRESETS = {
@@ -157,7 +179,7 @@ export const COLUMN_PRESETS = {
label: 'Straight Round', label: 'Straight Round',
style: 'plain', style: 'plain',
crossSection: 'round', crossSection: 'round',
height: 2.9, height: 2.5,
radius: 0.22, radius: 0.22,
width: 0.44, width: 0.44,
depth: 0.44, depth: 0.44,
@@ -207,7 +229,7 @@ export const COLUMN_PRESETS = {
label: 'Square Block', label: 'Square Block',
style: 'faceted', style: 'faceted',
crossSection: 'square', crossSection: 'square',
height: 2.9, height: 2.5,
radius: 0.24, radius: 0.24,
width: 0.48, width: 0.48,
depth: 0.48, depth: 0.48,
@@ -257,7 +279,7 @@ export const COLUMN_PRESETS = {
label: 'Tapered Round', label: 'Tapered Round',
style: 'plain', style: 'plain',
crossSection: 'round', crossSection: 'round',
height: 3, height: 2.5,
radius: 0.23, radius: 0.23,
width: 0.46, width: 0.46,
depth: 0.46, depth: 0.46,
@@ -307,7 +329,7 @@ export const COLUMN_PRESETS = {
label: 'Soft Bulged', label: 'Soft Bulged',
style: 'plain', style: 'plain',
crossSection: 'round', crossSection: 'round',
height: 2.9, height: 2.5,
radius: 0.22, radius: 0.22,
width: 0.44, width: 0.44,
depth: 0.44, depth: 0.44,
@@ -357,7 +379,7 @@ export const COLUMN_PRESETS = {
label: 'Hourglass', label: 'Hourglass',
style: 'plain', style: 'plain',
crossSection: 'round', crossSection: 'round',
height: 2.9, height: 2.5,
radius: 0.22, radius: 0.22,
width: 0.44, width: 0.44,
depth: 0.44, depth: 0.44,
@@ -403,6 +425,566 @@ export const COLUMN_PRESETS = {
dentilCount: 0, dentilCount: 0,
beadCount: 0, beadCount: 0,
}, },
aFrameSupport: {
label: 'A-Frame Support',
supportStyle: 'a-frame',
style: 'faceted',
crossSection: 'rectangular',
height: 2.5,
radius: 0.08,
width: 0.16,
depth: 0.16,
edgeSoftness: 0.012,
baseHeight: 0,
capitalHeight: 0,
shaftProfile: 'straight',
shaftTaper: 0,
shaftBulge: 0,
shaftStartScale: 1,
shaftEndScale: 1,
shaftSegmentCount: 1,
shaftTwistStep: 0,
shaftCornerRadius: 0.012,
shaftDetail: 'none',
baseStyle: 'none',
baseWidthScale: 1,
baseDepthScale: 1,
baseTierCount: 1,
baseStepSpread: 0.34,
basePlinthHeightRatio: 0.44,
baseRoundBandScale: 0.92,
baseNeckScale: 0.72,
baseRoundBandCount: 0,
baseRibCount: 0,
baseCarvingLevel: 0,
capitalStyle: 'none',
capitalWidthScale: 1,
capitalDepthScale: 1,
capitalTierCount: 1,
capitalStepSpread: 0.34,
capitalBandCount: 0,
capitalCarvingLevel: 0,
ringCount: 0,
ringSpread: 0.16,
fluteCount: 0,
spiralTwist: 0,
spiralRibCount: 0,
panelCount: 0,
latheRingCount: 0,
carvingLevel: 0,
lowerBandEnabled: false,
dentilCount: 0,
beadCount: 0,
braceWidth: 0.16,
braceDepth: 0.16,
braceBottomSpread: 1.2,
braceTopSpread: 0.06,
bracePlateEnabled: false,
},
yFrameSupport: {
label: 'Y Support',
supportStyle: 'y-frame',
style: 'faceted',
crossSection: 'rectangular',
height: 2.5,
radius: 0.08,
width: 0.16,
depth: 0.16,
edgeSoftness: 0.012,
baseHeight: 0,
capitalHeight: 0,
shaftProfile: 'straight',
shaftTaper: 0,
shaftBulge: 0,
shaftStartScale: 1,
shaftEndScale: 1,
shaftSegmentCount: 1,
shaftTwistStep: 0,
shaftCornerRadius: 0.012,
shaftDetail: 'none',
baseStyle: 'none',
baseWidthScale: 1,
baseDepthScale: 1,
baseTierCount: 1,
baseStepSpread: 0.34,
basePlinthHeightRatio: 0.44,
baseRoundBandScale: 0.92,
baseNeckScale: 0.72,
baseRoundBandCount: 0,
baseRibCount: 0,
baseCarvingLevel: 0,
capitalStyle: 'none',
capitalWidthScale: 1,
capitalDepthScale: 1,
capitalTierCount: 1,
capitalStepSpread: 0.34,
capitalBandCount: 0,
capitalCarvingLevel: 0,
ringCount: 0,
ringSpread: 0.16,
fluteCount: 0,
spiralTwist: 0,
spiralRibCount: 0,
panelCount: 0,
latheRingCount: 0,
carvingLevel: 0,
lowerBandEnabled: false,
dentilCount: 0,
beadCount: 0,
braceWidth: 0.16,
braceDepth: 0.16,
braceBottomSpread: 0.2,
braceTopSpread: 1,
bracePlateEnabled: false,
},
vFrameSupport: {
label: 'V Support',
supportStyle: 'v-frame',
style: 'faceted',
crossSection: 'rectangular',
height: 2.5,
radius: 0.08,
width: 0.16,
depth: 0.16,
edgeSoftness: 0.012,
baseHeight: 0,
capitalHeight: 0,
shaftProfile: 'straight',
shaftTaper: 0,
shaftBulge: 0,
shaftStartScale: 1,
shaftEndScale: 1,
shaftSegmentCount: 1,
shaftTwistStep: 0,
shaftCornerRadius: 0.012,
shaftDetail: 'none',
baseStyle: 'none',
baseWidthScale: 1,
baseDepthScale: 1,
baseTierCount: 1,
baseStepSpread: 0.34,
basePlinthHeightRatio: 0.44,
baseRoundBandScale: 0.92,
baseNeckScale: 0.72,
baseRoundBandCount: 0,
baseRibCount: 0,
baseCarvingLevel: 0,
capitalStyle: 'none',
capitalWidthScale: 1,
capitalDepthScale: 1,
capitalTierCount: 1,
capitalStepSpread: 0.34,
capitalBandCount: 0,
capitalCarvingLevel: 0,
ringCount: 0,
ringSpread: 0.16,
fluteCount: 0,
spiralTwist: 0,
spiralRibCount: 0,
panelCount: 0,
latheRingCount: 0,
carvingLevel: 0,
lowerBandEnabled: false,
dentilCount: 0,
beadCount: 0,
braceWidth: 0.16,
braceDepth: 0.16,
braceBottomSpread: 0.2,
braceTopSpread: 1,
bracePlateEnabled: false,
},
xBraceSupport: {
label: 'X Brace',
supportStyle: 'x-brace',
style: 'faceted',
crossSection: 'rectangular',
height: 2.5,
radius: 0.07,
width: 0.14,
depth: 0.14,
edgeSoftness: 0.01,
baseHeight: 0,
capitalHeight: 0,
shaftProfile: 'straight',
shaftTaper: 0,
shaftBulge: 0,
shaftStartScale: 1,
shaftEndScale: 1,
shaftSegmentCount: 1,
shaftTwistStep: 0,
shaftCornerRadius: 0.01,
shaftDetail: 'none',
baseStyle: 'none',
baseWidthScale: 1,
baseDepthScale: 1,
baseTierCount: 1,
baseStepSpread: 0.34,
basePlinthHeightRatio: 0.44,
baseRoundBandScale: 0.92,
baseNeckScale: 0.72,
baseRoundBandCount: 0,
baseRibCount: 0,
baseCarvingLevel: 0,
capitalStyle: 'none',
capitalWidthScale: 1,
capitalDepthScale: 1,
capitalTierCount: 1,
capitalStepSpread: 0.34,
capitalBandCount: 0,
capitalCarvingLevel: 0,
ringCount: 0,
ringSpread: 0.16,
fluteCount: 0,
spiralTwist: 0,
spiralRibCount: 0,
panelCount: 0,
latheRingCount: 0,
carvingLevel: 0,
lowerBandEnabled: false,
dentilCount: 0,
beadCount: 0,
braceWidth: 0.14,
braceDepth: 0.14,
braceBottomSpread: 1,
braceTopSpread: 1,
bracePlateEnabled: false,
},
kBraceSupport: {
label: 'K Brace',
supportStyle: 'k-brace',
style: 'faceted',
crossSection: 'rectangular',
height: 2.5,
radius: 0.07,
width: 0.14,
depth: 0.14,
edgeSoftness: 0.01,
baseHeight: 0,
capitalHeight: 0,
shaftProfile: 'straight',
shaftTaper: 0,
shaftBulge: 0,
shaftStartScale: 1,
shaftEndScale: 1,
shaftSegmentCount: 1,
shaftTwistStep: 0,
shaftCornerRadius: 0.01,
shaftDetail: 'none',
baseStyle: 'none',
baseWidthScale: 1,
baseDepthScale: 1,
baseTierCount: 1,
baseStepSpread: 0.34,
basePlinthHeightRatio: 0.44,
baseRoundBandScale: 0.92,
baseNeckScale: 0.72,
baseRoundBandCount: 0,
baseRibCount: 0,
baseCarvingLevel: 0,
capitalStyle: 'none',
capitalWidthScale: 1,
capitalDepthScale: 1,
capitalTierCount: 1,
capitalStepSpread: 0.34,
capitalBandCount: 0,
capitalCarvingLevel: 0,
ringCount: 0,
ringSpread: 0.16,
fluteCount: 0,
spiralTwist: 0,
spiralRibCount: 0,
panelCount: 0,
latheRingCount: 0,
carvingLevel: 0,
lowerBandEnabled: false,
dentilCount: 0,
beadCount: 0,
braceWidth: 0.14,
braceDepth: 0.14,
braceBottomSpread: 1,
braceTopSpread: 1,
bracePlateEnabled: false,
},
singleStrutSupport: {
label: 'Single Strut',
supportStyle: 'single-strut',
style: 'faceted',
crossSection: 'rectangular',
height: 2.5,
radius: 0.07,
width: 0.14,
depth: 0.14,
edgeSoftness: 0.01,
baseHeight: 0,
capitalHeight: 0,
shaftProfile: 'straight',
shaftTaper: 0,
shaftBulge: 0,
shaftStartScale: 1,
shaftEndScale: 1,
shaftSegmentCount: 1,
shaftTwistStep: 0,
shaftCornerRadius: 0.01,
shaftDetail: 'none',
baseStyle: 'none',
baseWidthScale: 1,
baseDepthScale: 1,
baseTierCount: 1,
baseStepSpread: 0.34,
basePlinthHeightRatio: 0.44,
baseRoundBandScale: 0.92,
baseNeckScale: 0.72,
baseRoundBandCount: 0,
baseRibCount: 0,
baseCarvingLevel: 0,
capitalStyle: 'none',
capitalWidthScale: 1,
capitalDepthScale: 1,
capitalTierCount: 1,
capitalStepSpread: 0.34,
capitalBandCount: 0,
capitalCarvingLevel: 0,
ringCount: 0,
ringSpread: 0.16,
fluteCount: 0,
spiralTwist: 0,
spiralRibCount: 0,
panelCount: 0,
latheRingCount: 0,
carvingLevel: 0,
lowerBandEnabled: false,
dentilCount: 0,
beadCount: 0,
braceWidth: 0.14,
braceDepth: 0.14,
braceBottomSpread: 1,
braceTopSpread: 1,
bracePlateEnabled: false,
},
tripodSupport: {
label: 'Tripod Support',
supportStyle: 'tripod',
style: 'faceted',
crossSection: 'rectangular',
height: 2.5,
radius: 0.07,
width: 0.14,
depth: 0.14,
edgeSoftness: 0.01,
baseHeight: 0,
capitalHeight: 0,
shaftProfile: 'straight',
shaftTaper: 0,
shaftBulge: 0,
shaftStartScale: 1,
shaftEndScale: 1,
shaftSegmentCount: 1,
shaftTwistStep: 0,
shaftCornerRadius: 0.01,
shaftDetail: 'none',
baseStyle: 'none',
baseWidthScale: 1,
baseDepthScale: 1,
baseTierCount: 1,
baseStepSpread: 0.34,
basePlinthHeightRatio: 0.44,
baseRoundBandScale: 0.92,
baseNeckScale: 0.72,
baseRoundBandCount: 0,
baseRibCount: 0,
baseCarvingLevel: 0,
capitalStyle: 'none',
capitalWidthScale: 1,
capitalDepthScale: 1,
capitalTierCount: 1,
capitalStepSpread: 0.34,
capitalBandCount: 0,
capitalCarvingLevel: 0,
ringCount: 0,
ringSpread: 0.16,
fluteCount: 0,
spiralTwist: 0,
spiralRibCount: 0,
panelCount: 0,
latheRingCount: 0,
carvingLevel: 0,
lowerBandEnabled: false,
dentilCount: 0,
beadCount: 0,
braceWidth: 0.14,
braceDepth: 0.14,
braceBottomSpread: 1.1,
braceTopSpread: 1.1,
bracePlateEnabled: false,
},
trestleSupport: {
label: 'Trestle Frame',
supportStyle: 'trestle',
style: 'faceted',
crossSection: 'rectangular',
height: 2.5,
radius: 0.07,
width: 0.14,
depth: 0.14,
edgeSoftness: 0.01,
baseHeight: 0,
capitalHeight: 0,
shaftProfile: 'straight',
shaftTaper: 0,
shaftBulge: 0,
shaftStartScale: 1,
shaftEndScale: 1,
shaftSegmentCount: 1,
shaftTwistStep: 0,
shaftCornerRadius: 0.01,
shaftDetail: 'none',
baseStyle: 'none',
baseWidthScale: 1,
baseDepthScale: 1,
baseTierCount: 1,
baseStepSpread: 0.34,
basePlinthHeightRatio: 0.44,
baseRoundBandScale: 0.92,
baseNeckScale: 0.72,
baseRoundBandCount: 0,
baseRibCount: 0,
baseCarvingLevel: 0,
capitalStyle: 'none',
capitalWidthScale: 1,
capitalDepthScale: 1,
capitalTierCount: 1,
capitalStepSpread: 0.34,
capitalBandCount: 0,
capitalCarvingLevel: 0,
ringCount: 0,
ringSpread: 0.16,
fluteCount: 0,
spiralTwist: 0,
spiralRibCount: 0,
panelCount: 0,
latheRingCount: 0,
carvingLevel: 0,
lowerBandEnabled: false,
dentilCount: 0,
beadCount: 0,
braceWidth: 0.14,
braceDepth: 0.14,
braceBottomSpread: 1.2,
braceTopSpread: 1,
bracePlateEnabled: false,
},
portalFrameSupport: {
label: 'Portal Frame',
supportStyle: 'portal-frame',
style: 'faceted',
crossSection: 'rectangular',
height: 2.5,
radius: 0.08,
width: 0.16,
depth: 0.16,
edgeSoftness: 0.012,
baseHeight: 0,
capitalHeight: 0,
shaftProfile: 'straight',
shaftTaper: 0,
shaftBulge: 0,
shaftStartScale: 1,
shaftEndScale: 1,
shaftSegmentCount: 1,
shaftTwistStep: 0,
shaftCornerRadius: 0.012,
shaftDetail: 'none',
baseStyle: 'none',
baseWidthScale: 1,
baseDepthScale: 1,
baseTierCount: 1,
baseStepSpread: 0.34,
basePlinthHeightRatio: 0.44,
baseRoundBandScale: 0.92,
baseNeckScale: 0.72,
baseRoundBandCount: 0,
baseRibCount: 0,
baseCarvingLevel: 0,
capitalStyle: 'none',
capitalWidthScale: 1,
capitalDepthScale: 1,
capitalTierCount: 1,
capitalStepSpread: 0.34,
capitalBandCount: 0,
capitalCarvingLevel: 0,
ringCount: 0,
ringSpread: 0.16,
fluteCount: 0,
spiralTwist: 0,
spiralRibCount: 0,
panelCount: 0,
latheRingCount: 0,
carvingLevel: 0,
lowerBandEnabled: false,
dentilCount: 0,
beadCount: 0,
braceWidth: 0.16,
braceDepth: 0.16,
braceBottomSpread: 1.4,
braceTopSpread: 0.2,
bracePlateEnabled: false,
},
boxFrameSupport: {
label: 'Box Frame',
supportStyle: 'box-frame',
style: 'faceted',
crossSection: 'rectangular',
height: 2.5,
radius: 0.07,
width: 0.14,
depth: 0.14,
edgeSoftness: 0.01,
baseHeight: 0,
capitalHeight: 0,
shaftProfile: 'straight',
shaftTaper: 0,
shaftBulge: 0,
shaftStartScale: 1,
shaftEndScale: 1,
shaftSegmentCount: 1,
shaftTwistStep: 0,
shaftCornerRadius: 0.01,
shaftDetail: 'none',
baseStyle: 'none',
baseWidthScale: 1,
baseDepthScale: 1,
baseTierCount: 1,
baseStepSpread: 0.34,
basePlinthHeightRatio: 0.44,
baseRoundBandScale: 0.92,
baseNeckScale: 0.72,
baseRoundBandCount: 0,
baseRibCount: 0,
baseCarvingLevel: 0,
capitalStyle: 'none',
capitalWidthScale: 1,
capitalDepthScale: 1,
capitalTierCount: 1,
capitalStepSpread: 0.34,
capitalBandCount: 0,
capitalCarvingLevel: 0,
ringCount: 0,
ringSpread: 0.16,
fluteCount: 0,
spiralTwist: 0,
spiralRibCount: 0,
panelCount: 0,
latheRingCount: 0,
carvingLevel: 0,
lowerBandEnabled: false,
dentilCount: 0,
beadCount: 0,
braceWidth: 0.14,
braceDepth: 0.14,
braceBottomSpread: 1.4,
braceTopSpread: 1,
bracePlateEnabled: false,
},
} as const satisfies Record<string, { label: string } & Partial<z.input<typeof ColumnNode>>> } as const satisfies Record<string, { label: string } & Partial<z.input<typeof ColumnNode>>>
export type ColumnPresetId = keyof typeof COLUMN_PRESETS export type ColumnPresetId = keyof typeof COLUMN_PRESETS
+2
View File
@@ -23,6 +23,7 @@ export const FenceNode = BaseNode.extend({
groundClearance: z.number().default(0), groundClearance: z.number().default(0),
edgeInset: z.number().default(0.015), edgeInset: z.number().default(0.015),
baseStyle: FenceBaseStyle.default('grounded'), baseStyle: FenceBaseStyle.default('grounded'),
showInfill: z.boolean().default(true),
color: z.string().default('#ffffff'), color: z.string().default('#ffffff'),
style: FenceStyle.default('slat'), style: FenceStyle.default('slat'),
}).describe( }).describe(
@@ -33,6 +34,7 @@ export const FenceNode = BaseNode.extend({
- curveOffset: midpoint sagitta offset used to bend the fence into an arc - curveOffset: midpoint sagitta offset used to bend the fence into an arc
- baseHeight/postSpacing/postSize/topRailHeight: exact geometric controls from the plan3D fence model - baseHeight/postSpacing/postSize/topRailHeight: exact geometric controls from the plan3D fence model
- groundClearance/edgeInset/baseStyle: fence support and inset configuration - groundClearance/edgeInset/baseStyle: fence support and inset configuration
- showInfill: whether to draw intermediate posts/slats between end posts
- color/style: visual appearance options - color/style: visual appearance options
`, `,
) )
+141 -1
View File
@@ -9,6 +9,9 @@ import type { CollectionId } from '../../schema/collections'
import type { SceneState } from '../use-scene' import type { SceneState } from '../use-scene'
type AnyContainerNode = AnyNode & { children: string[] } type AnyContainerNode = AnyNode & { children: string[] }
type NodeCreateOp = { node: AnyNode; parentId?: AnyNodeId }
type NodeUpdateOp = { id: AnyNodeId; data: Partial<AnyNode> }
type NodeDeleteOp = AnyNodeId
type WallAttachmentUpdate = { id: AnyNodeId; data: Partial<AnyNode> } type WallAttachmentUpdate = { id: AnyNodeId; data: Partial<AnyNode> }
type WallMergePlan = { type WallMergePlan = {
primaryWallId: AnyNodeId primaryWallId: AnyNodeId
@@ -232,7 +235,7 @@ function buildWallMergePlans(
export const createNodesAction = ( export const createNodesAction = (
set: (fn: (state: SceneState) => Partial<SceneState>) => void, set: (fn: (state: SceneState) => Partial<SceneState>) => void,
get: () => SceneState, get: () => SceneState,
ops: { node: AnyNode; parentId?: AnyNodeId }[], ops: NodeCreateOp[],
) => { ) => {
if (get().readOnly) return if (get().readOnly) return
set((state) => { set((state) => {
@@ -281,6 +284,143 @@ export const createNodesAction = (
}) })
} }
export const applyNodeChangesAction = (
set: (fn: (state: SceneState) => Partial<SceneState>) => void,
get: () => SceneState,
changes: { create?: NodeCreateOp[]; update?: NodeUpdateOp[]; delete?: NodeDeleteOp[] },
) => {
if (get().readOnly) return
const createOps = changes.create ?? []
const updateOps = changes.update ?? []
const deleteOps = changes.delete ?? []
const nodesToMarkDirty = new Set<AnyNodeId>()
const parentsToMarkDirty = new Set<AnyNodeId>()
set((state) => {
const nextNodes = { ...state.nodes }
const nextCollections = { ...state.collections }
const nextRootIds = [...state.rootNodeIds]
let resolvedRootIds = nextRootIds
for (const { id, data } of updateOps) {
const currentNode = nextNodes[id]
if (!currentNode) continue
if (data.parentId !== undefined && data.parentId !== currentNode.parentId) {
const oldParentId = currentNode.parentId as AnyNodeId | null
if (oldParentId && nextNodes[oldParentId]) {
const oldParent = nextNodes[oldParentId] as AnyContainerNode
nextNodes[oldParent.id] = {
...oldParent,
children: oldParent.children.filter((childId) => childId !== id),
} as AnyNode
parentsToMarkDirty.add(oldParent.id)
}
const newParentId = data.parentId as AnyNodeId | null
if (newParentId && nextNodes[newParentId]) {
const newParent = nextNodes[newParentId] as AnyContainerNode
nextNodes[newParent.id] = {
...newParent,
children: Array.from(new Set([...newParent.children, id])),
} as AnyNode
parentsToMarkDirty.add(newParent.id)
}
}
nextNodes[id] = { ...currentNode, ...data } as AnyNode
nodesToMarkDirty.add(id)
}
for (const { node, parentId } of createOps) {
const effectiveParentId = parentId ?? (node.parentId as AnyNodeId | null) ?? null
const newNode = {
...node,
parentId: effectiveParentId,
} as AnyNode
nextNodes[newNode.id as AnyNodeId] = newNode
nodesToMarkDirty.add(newNode.id as AnyNodeId)
if (effectiveParentId && nextNodes[effectiveParentId]) {
const parent = nextNodes[effectiveParentId]
if ('children' in parent && Array.isArray(parent.children)) {
nextNodes[effectiveParentId] = {
...parent,
children: Array.from(new Set([...parent.children, newNode.id])) as any,
}
parentsToMarkDirty.add(effectiveParentId)
}
} else if (!effectiveParentId && !nextRootIds.includes(newNode.id as AnyNodeId)) {
nextRootIds.push(newNode.id as AnyNodeId)
}
}
const allIdsToDelete = new Set<AnyNodeId>()
const collectDelete = (id: AnyNodeId) => {
if (allIdsToDelete.has(id)) return
allIdsToDelete.add(id)
const node = nextNodes[id]
if (node && 'children' in node && Array.isArray(node.children)) {
for (const childId of node.children) {
collectDelete(childId as AnyNodeId)
}
}
}
for (const id of deleteOps) {
collectDelete(id)
}
for (const id of allIdsToDelete) {
const node = nextNodes[id]
if (!node) continue
const parentId = node.parentId as AnyNodeId | null
if (parentId && nextNodes[parentId] && !allIdsToDelete.has(parentId)) {
const parent = nextNodes[parentId] as AnyContainerNode
if (parent.children) {
nextNodes[parent.id] = {
...parent,
children: parent.children.filter((childId) => childId !== id),
} as AnyNode
parentsToMarkDirty.add(parent.id)
}
}
resolvedRootIds = resolvedRootIds.filter((rootId) => rootId !== id)
if ('collectionIds' in node && node.collectionIds) {
for (const collectionId of node.collectionIds as CollectionId[]) {
const collection = nextCollections[collectionId]
if (collection) {
nextCollections[collectionId] = {
...collection,
nodeIds: collection.nodeIds.filter((nodeId) => nodeId !== id),
}
}
}
}
delete nextNodes[id]
}
return { nodes: nextNodes, rootNodeIds: resolvedRootIds, collections: nextCollections }
})
nodesToMarkDirty.forEach((id) => get().markDirty(id))
parentsToMarkDirty.forEach((id) => {
get().markDirty(id)
const parent = get().nodes[id]
if (parent && 'children' in parent && Array.isArray(parent.children)) {
for (const childId of parent.children) {
get().markDirty(childId as AnyNodeId)
}
}
})
}
export const updateNodesAction = ( export const updateNodesAction = (
set: (fn: (state: SceneState) => Partial<SceneState>) => void, set: (fn: (state: SceneState) => Partial<SceneState>) => void,
get: () => SceneState, get: () => SceneState,
+6
View File
@@ -438,6 +438,11 @@ export type SceneState = {
createNode: (node: AnyNode, parentId?: AnyNodeId) => void createNode: (node: AnyNode, parentId?: AnyNodeId) => void
createNodes: (ops: { node: AnyNode; parentId?: AnyNodeId }[]) => void createNodes: (ops: { node: AnyNode; parentId?: AnyNodeId }[]) => void
applyNodeChanges: (changes: {
create?: { node: AnyNode; parentId?: AnyNodeId }[]
update?: { id: AnyNodeId; data: Partial<AnyNode> }[]
delete?: AnyNodeId[]
}) => void
updateNode: (id: AnyNodeId, data: Partial<AnyNode>) => void updateNode: (id: AnyNodeId, data: Partial<AnyNode>) => void
updateNodes: (updates: { id: AnyNodeId; data: Partial<AnyNode> }[]) => void updateNodes: (updates: { id: AnyNodeId; data: Partial<AnyNode> }[]) => void
@@ -586,6 +591,7 @@ const useScene: UseSceneStore = create<SceneState>()(
createNodes: (ops) => nodeActions.createNodesAction(set, get, ops), createNodes: (ops) => nodeActions.createNodesAction(set, get, ops),
createNode: (node, parentId) => nodeActions.createNodesAction(set, get, [{ node, parentId }]), createNode: (node, parentId) => nodeActions.createNodesAction(set, get, [{ node, parentId }]),
applyNodeChanges: (changes) => nodeActions.applyNodeChangesAction(set, get, changes),
updateNodes: (updates) => nodeActions.updateNodesAction(set, get, updates), updateNodes: (updates) => nodeActions.updateNodesAction(set, get, updates),
updateNode: (id, data) => nodeActions.updateNodesAction(set, get, [{ id, data }]), updateNode: (id, data) => nodeActions.updateNodesAction(set, get, [{ id, data }]),
+227
View File
@@ -0,0 +1,227 @@
import type { WallNode } from '../../schema'
const AXIS_EPSILON = 1e-6
export type WallPlanPoint = [number, number]
export type WallMoveAxis = 'x' | 'z'
export type WallMoveEndpoint = 'start' | 'end'
export type WallMoveBridgePlan<TWall extends Pick<WallNode, 'id' | 'start' | 'end'>> = {
wall: TWall
originalPoint: WallPlanPoint
movedEndpoint: WallMoveEndpoint
}
export type WallMoveLinkedWallTargetPlan<
TWall extends Pick<WallNode, 'id' | 'start' | 'end'>,
> = {
wall: TWall
originalPoint: WallPlanPoint
targetPoint: WallPlanPoint
}
export type WallMoveJunctionPlan<TWall extends Pick<WallNode, 'id' | 'start' | 'end'>> = {
linkedWallsToMove: TWall[]
linkedWallTargetPlans: Array<WallMoveLinkedWallTargetPlan<TWall>>
bridgePlans: Array<WallMoveBridgePlan<TWall>>
wallsToDelete: TWall[]
}
export function getPerpendicularWallMoveAxis(
start: WallPlanPoint,
end: WallPlanPoint,
): WallMoveAxis | null {
const wallDeltaX = Math.abs(end[0] - start[0])
const wallDeltaZ = Math.abs(end[1] - start[1])
if (wallDeltaX < AXIS_EPSILON && wallDeltaZ < AXIS_EPSILON) return null
return wallDeltaX >= wallDeltaZ ? 'z' : 'x'
}
export function constrainWallMoveDeltaToAxis(
deltaX: number,
deltaZ: number,
axis: WallMoveAxis | null,
): WallPlanPoint {
if (axis === 'x') return [deltaX, 0]
if (axis === 'z') return [0, deltaZ]
return [deltaX, deltaZ]
}
function pointsEqual(a: WallPlanPoint, b: WallPlanPoint) {
return Math.abs(a[0] - b[0]) <= AXIS_EPSILON && Math.abs(a[1] - b[1]) <= AXIS_EPSILON
}
function wallTouchesPoint(wall: Pick<WallNode, 'start' | 'end'>, point: WallPlanPoint) {
return pointsEqual(wall.start, point) || pointsEqual(wall.end, point)
}
function otherWallEndpoint(wall: Pick<WallNode, 'start' | 'end'>, point: WallPlanPoint) {
return pointsEqual(wall.start, point) ? wall.end : wall.start
}
type MoveWallRelation = 'same-direction' | 'opposite-direction' | 'off-axis' | 'stationary'
type RelatedWallEntry<TWall extends Pick<WallNode, 'id' | 'start' | 'end'>> = {
wall: TWall
relation: MoveWallRelation
}
function wallLengthFromPoint(wall: Pick<WallNode, 'start' | 'end'>, point: WallPlanPoint) {
const freeEndpoint = otherWallEndpoint(wall, point)
return Math.hypot(freeEndpoint[0] - point[0], freeEndpoint[1] - point[1])
}
function getMoveWallRelation(
wall: Pick<WallNode, 'start' | 'end'>,
sharedPoint: WallPlanPoint,
nextPoint: WallPlanPoint,
): MoveWallRelation {
const moveX = nextPoint[0] - sharedPoint[0]
const moveZ = nextPoint[1] - sharedPoint[1]
const moveLength = Math.hypot(moveX, moveZ)
if (moveLength < AXIS_EPSILON) return 'stationary'
const freeEndpoint = otherWallEndpoint(wall, sharedPoint)
const wallX = freeEndpoint[0] - sharedPoint[0]
const wallZ = freeEndpoint[1] - sharedPoint[1]
const wallLength = Math.hypot(wallX, wallZ)
if (wallLength < AXIS_EPSILON) return 'stationary'
const normalizedCross = Math.abs(moveX * wallZ - moveZ * wallX) / (moveLength * wallLength)
if (normalizedCross > 1e-4) return 'off-axis'
const normalizedDot = (moveX * wallX + moveZ * wallZ) / (moveLength * wallLength)
return normalizedDot >= 0 ? 'same-direction' : 'opposite-direction'
}
export function planWallMoveJunctions<TWall extends Pick<WallNode, 'id' | 'start' | 'end'>>(
linkedWalls: TWall[],
originalStart: WallPlanPoint,
originalEnd: WallPlanPoint,
nextStart: WallPlanPoint,
nextEnd: WallPlanPoint,
): WallMoveJunctionPlan<TWall> {
const linkedWallsToMove = new Map<TWall['id'], TWall>()
const linkedWallTargetPlans = new Map<TWall['id'], WallMoveLinkedWallTargetPlan<TWall>>()
const bridgePlans = new Map<string, WallMoveBridgePlan<TWall>>()
const wallsToDelete = new Map<TWall['id'], TWall>()
const addStandardEndpointPlan = (
endpoint: WallMoveEndpoint,
point: WallPlanPoint,
nextPoint: WallPlanPoint,
relatedWalls: Array<RelatedWallEntry<TWall>>,
keySuffix = '',
useTargetPlans = false,
) => {
const hasSideBranch = relatedWalls.some((entry) => entry.relation === 'off-axis')
const hasOppositeBridge = relatedWalls.some(
(entry) => entry.relation === 'opposite-direction' && hasSideBranch,
)
for (const { wall, relation } of relatedWalls) {
if (
relation === 'stationary' ||
relation === 'same-direction' ||
(relation === 'opposite-direction' && !hasSideBranch)
) {
if (useTargetPlans) {
linkedWallTargetPlans.set(wall.id, {
wall,
originalPoint: point,
targetPoint: nextPoint,
})
} else {
linkedWallsToMove.set(wall.id, wall)
}
continue
}
if (relation === 'off-axis' && hasOppositeBridge) {
continue
}
bridgePlans.set(`${wall.id}:${endpoint}${keySuffix}`, {
wall,
originalPoint: point,
movedEndpoint: endpoint,
})
}
}
const addEndpointPlan = (
endpoint: WallMoveEndpoint,
point: WallPlanPoint,
nextPoint: WallPlanPoint,
) => {
const moveLength = Math.hypot(nextPoint[0] - point[0], nextPoint[1] - point[1])
const linkedAtEndpoint = linkedWalls
.filter((wall) => wallTouchesPoint(wall, point))
.map((wall) => ({
wall,
relation: getMoveWallRelation(wall, point, nextPoint),
}))
const consumedSameDirectionWall = linkedAtEndpoint
.filter((entry) => entry.relation === 'same-direction')
.map((entry) => ({
...entry,
distance: wallLengthFromPoint(entry.wall, point),
}))
.filter((entry) => moveLength + AXIS_EPSILON >= entry.distance)
.sort((a, b) => a.distance - b.distance)[0]
if (consumedSameDirectionWall) {
const pivotPoint = [...otherWallEndpoint(consumedSameDirectionWall.wall, point)] as WallPlanPoint
const bridgeSource = linkedAtEndpoint.find((entry) => entry.relation === 'opposite-direction')
wallsToDelete.set(consumedSameDirectionWall.wall.id, consumedSameDirectionWall.wall)
linkedWallTargetPlans.set(consumedSameDirectionWall.wall.id, {
wall: consumedSameDirectionWall.wall,
originalPoint: point,
targetPoint: pivotPoint,
})
if (bridgeSource) {
linkedWallTargetPlans.set(bridgeSource.wall.id, {
wall: bridgeSource.wall,
originalPoint: point,
targetPoint: pivotPoint,
})
bridgePlans.set(`${bridgeSource.wall.id}:${endpoint}:through`, {
wall: bridgeSource.wall,
originalPoint: pivotPoint,
movedEndpoint: endpoint,
})
return
}
const linkedAtPivot = linkedWalls
.filter(
(wall) => wall.id !== consumedSameDirectionWall.wall.id && wallTouchesPoint(wall, pivotPoint),
)
.map((wall) => ({
wall,
relation: getMoveWallRelation(wall, pivotPoint, nextPoint),
}))
addStandardEndpointPlan(endpoint, pivotPoint, nextPoint, linkedAtPivot, ':through-pivot', true)
return
}
addStandardEndpointPlan(endpoint, point, nextPoint, linkedAtEndpoint)
}
addEndpointPlan('start', originalStart, nextStart)
addEndpointPlan('end', originalEnd, nextEnd)
return {
linkedWallsToMove: Array.from(linkedWallsToMove.values()),
linkedWallTargetPlans: Array.from(linkedWallTargetPlans.values()),
bridgePlans: Array.from(bridgePlans.values()),
wallsToDelete: Array.from(wallsToDelete.values()),
}
}
@@ -16,6 +16,7 @@ export type FloorplanActionMenuEntry = {
onDelete: FloorplanActionMenuHandler onDelete: FloorplanActionMenuHandler
onMove: FloorplanActionMenuHandler onMove: FloorplanActionMenuHandler
onAddHole?: FloorplanActionMenuHandler onAddHole?: FloorplanActionMenuHandler
onCurve?: FloorplanActionMenuHandler
onDuplicate?: FloorplanActionMenuHandler onDuplicate?: FloorplanActionMenuHandler
} }
@@ -81,6 +82,7 @@ export const FloorplanActionMenuLayer = memo(function FloorplanActionMenuLayer({
> >
<NodeActionMenu <NodeActionMenu
onAddHole={entry.onAddHole} onAddHole={entry.onAddHole}
onCurve={entry.onCurve}
onDelete={entry.onDelete} onDelete={entry.onDelete}
onDuplicate={entry.onDuplicate} onDuplicate={entry.onDuplicate}
onMove={entry.onMove} onMove={entry.onMove}
@@ -434,7 +434,11 @@ export function FloatingActionMenu() {
? handleDuplicate ? handleDuplicate
: undefined : undefined
} }
onMove={node && !DELETE_ONLY_TYPES.includes(node.type) ? handleMove : undefined} onMove={
node && node.type !== 'wall' && !DELETE_ONLY_TYPES.includes(node.type)
? handleMove
: undefined
}
onPointerDown={(e) => e.stopPropagation()} onPointerDown={(e) => e.stopPropagation()}
onPointerUp={(e) => e.stopPropagation()} onPointerUp={(e) => e.stopPropagation()}
/> />
@@ -39,6 +39,7 @@ import {
sceneRegistry, sceneRegistry,
useLiveTransforms, useLiveTransforms,
useScene, useScene,
WallNode as WallNodeSchema,
type WallNode, type WallNode,
WindowNode, WindowNode,
ZoneNode as ZoneNodeSchema, ZoneNode as ZoneNodeSchema,
@@ -625,6 +626,12 @@ type FloorplanSpawnEntry = {
rotation: number rotation: number
} }
type FloorplanColumnEntry = {
column: ColumnNode
points: string
polygon: Point2D[]
}
type ReferenceFloorData = { type ReferenceFloorData = {
ceilingPolygons: CeilingPolygonEntry[] ceilingPolygons: CeilingPolygonEntry[]
columnEntries: ReferenceFloorColumnEntry[] columnEntries: ReferenceFloorColumnEntry[]
@@ -1768,6 +1775,56 @@ function getRotatedRectanglePolygon(
function getColumnPlanFootprint(column: ColumnNode): Point2D[] { function getColumnPlanFootprint(column: ColumnNode): Point2D[] {
const center = { x: column.position[0], y: column.position[2] } const center = { x: column.position[0], y: column.position[2] }
if (
column.supportStyle === 'a-frame' ||
column.supportStyle === 'y-frame' ||
column.supportStyle === 'v-frame' ||
column.supportStyle === 'x-brace' ||
column.supportStyle === 'k-brace' ||
column.supportStyle === 'single-strut' ||
column.supportStyle === 'tripod' ||
column.supportStyle === 'trestle' ||
column.supportStyle === 'portal-frame' ||
column.supportStyle === 'box-frame'
) {
const width = Math.max(
column.supportStyle === 'a-frame' ||
column.supportStyle === 'x-brace' ||
column.supportStyle === 'k-brace' ||
column.supportStyle === 'single-strut' ||
column.supportStyle === 'tripod' ||
column.supportStyle === 'trestle' ||
column.supportStyle === 'portal-frame' ||
column.supportStyle === 'box-frame'
? (column.braceBottomSpread ?? 1.2)
: 0,
column.braceTopSpread ??
(column.supportStyle === 'y-frame' ||
column.supportStyle === 'v-frame' ||
column.supportStyle === 'x-brace' ||
column.supportStyle === 'k-brace' ||
column.supportStyle === 'single-strut' ||
column.supportStyle === 'tripod' ||
column.supportStyle === 'trestle' ||
column.supportStyle === 'portal-frame' ||
column.supportStyle === 'box-frame'
? 1
: 0),
(column.braceWidth ?? column.width) * 2,
)
const depth = Math.max(
column.supportStyle === 'tripod' ||
column.supportStyle === 'trestle' ||
column.supportStyle === 'box-frame'
? (column.braceTopSpread ?? 1)
: 0,
column.braceDepth ?? column.depth,
0.08,
)
return getRotatedRectanglePolygon(center, width, depth, column.rotation)
}
const shaftWidth = const shaftWidth =
column.crossSection === 'round' || column.crossSection === 'round' ||
column.crossSection === 'octagonal' || column.crossSection === 'octagonal' ||
@@ -5742,6 +5799,12 @@ const FloorplanFenceLayer = memo(function FloorplanFenceLayer({
const fenceGlowOpacity = isDeleteHovered ? 0.18 : isActive ? 0.22 : isHovered ? 0.14 : 0 const fenceGlowOpacity = isDeleteHovered ? 0.18 : isActive ? 0.22 : isHovered ? 0.14 : 0
const fenceUnderlayWidth = isActive ? '6.5' : isHovered ? '6' : '5.2' const fenceUnderlayWidth = isActive ? '6.5' : isHovered ? '6' : '5.2'
const fenceStrokeWidth = isActive ? '2.6' : isHovered ? '2.35' : '2.05' const fenceStrokeWidth = isActive ? '2.6' : isHovered ? '2.35' : '2.05'
const showFenceInfill = fence.showInfill ?? true
const visibleMarkerFrames = showFenceInfill
? markerFrames
: markerFrames.filter(
(_, markerIndex) => markerIndex === 0 || markerIndex === markerFrames.length - 1,
)
const privacyMarkerWidth = clamp(fence.postSize * 0.58, 0.038, 0.068) const privacyMarkerWidth = clamp(fence.postSize * 0.58, 0.038, 0.068)
const privacyMarkerHeight = clamp( const privacyMarkerHeight = clamp(
Math.max(fence.baseHeight * 0.5, fence.postSize * 1.4), Math.max(fence.baseHeight * 0.5, fence.postSize * 1.4),
@@ -5792,7 +5855,7 @@ const FloorplanFenceLayer = memo(function FloorplanFenceLayer({
strokeWidth={fenceStrokeWidth} strokeWidth={fenceStrokeWidth}
vectorEffect="non-scaling-stroke" vectorEffect="non-scaling-stroke"
/> />
{markerFrames.map(({ angleDeg, point }, markerIndex) => { {visibleMarkerFrames.map(({ angleDeg, point }, markerIndex) => {
const svgPoint = toSvgPoint(point) const svgPoint = toSvgPoint(point)
if (fence.style === 'privacy') { if (fence.style === 'privacy') {
@@ -7492,6 +7555,7 @@ export function FloorplanPanel() {
const setPhase = useEditor((state) => state.setPhase) const setPhase = useEditor((state) => state.setPhase)
const setMovingFenceEndpoint = useEditor((state) => state.setMovingFenceEndpoint) const setMovingFenceEndpoint = useEditor((state) => state.setMovingFenceEndpoint)
const setMovingNode = useEditor((state) => state.setMovingNode) const setMovingNode = useEditor((state) => state.setMovingNode)
const setCurvingWall = useEditor((state) => state.setCurvingWall)
const movingFenceEndpoint = useEditor((state) => state.movingFenceEndpoint) const movingFenceEndpoint = useEditor((state) => state.movingFenceEndpoint)
const structureLayer = useEditor((state) => state.structureLayer) const structureLayer = useEditor((state) => state.structureLayer)
const setStructureLayer = useEditor((state) => state.setStructureLayer) const setStructureLayer = useEditor((state) => state.setStructureLayer)
@@ -8140,6 +8204,28 @@ export function FloorplanPanel() {
: entry, : entry,
) )
}, [zoneBoundaryDraft, zonePolygons]) }, [zoneBoundaryDraft, zonePolygons])
const floorplanColumnEntries = useMemo<FloorplanColumnEntry[]>(
() =>
levelDescendantNodes.flatMap((node) => {
if (!(node.type === 'column' && node.visible !== false)) {
return []
}
const polygon = getColumnPlanFootprint(node)
if (polygon.length < 3) {
return []
}
return [
{
column: node,
points: formatPolygonPoints(polygon),
polygon,
},
]
}),
[levelDescendantNodes],
)
const levelDescendantNodeById = useMemo( const levelDescendantNodeById = useMemo(
() => new Map(levelDescendantNodes.map((node) => [node.id, node] as const)), () => new Map(levelDescendantNodes.map((node) => [node.id, node] as const)),
[levelDescendantNodes], [levelDescendantNodes],
@@ -9240,6 +9326,7 @@ export function FloorplanPanel() {
selectedWallEntry, selectedWallEntry,
wallCurveDraft, wallCurveDraft,
]) ])
const canCurveSelectedWall = wallCurveHandles.length > 0
const slabVertexHandles = useMemo(() => { const slabVertexHandles = useMemo(() => {
if (!shouldShowSlabBoundaryHandles) { if (!shouldShowSlabBoundaryHandles) {
return [] return []
@@ -12967,6 +13054,7 @@ export function FloorplanPanel() {
) )
const { getFloorplanHitIdAtPoint, getFloorplanSelectionIdsInBounds } = useFloorplanHitTesting({ const { getFloorplanHitIdAtPoint, getFloorplanSelectionIdsInBounds } = useFloorplanHitTesting({
ceilingPolygons: displayCeilingPolygons, ceilingPolygons: displayCeilingPolygons,
columnPolygons: floorplanColumnEntries,
displaySlabPolygons, displaySlabPolygons,
displayWallPolygons, displayWallPolygons,
floorplanItemEntries, floorplanItemEntries,
@@ -13981,6 +14069,57 @@ export function FloorplanPanel() {
}, },
[selectedWallEntry, setMovingNode, setSelection], [selectedWallEntry, setMovingNode, setSelection],
) )
const duplicateSelectedWall = useCallback(() => {
const wall = selectedWallEntry?.wall
if (!wall?.parentId) {
return
}
sfxEmitter.emit('sfx:item-pick')
const cloned = structuredClone(wall) as Record<string, unknown>
delete cloned.id
cloned.children = []
cloned.metadata = {
...(typeof cloned.metadata === 'object' && cloned.metadata !== null ? cloned.metadata : {}),
isNew: true,
}
const temporal = useScene.temporal.getState()
temporal.pause()
try {
const duplicate = WallNodeSchema.parse(cloned)
useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)
setMovingNode(duplicate)
setSelection({ selectedIds: [] })
} catch (error) {
console.error('Failed to duplicate wall', error)
} finally {
temporal.resume()
}
}, [selectedWallEntry, setMovingNode, setSelection])
const handleSelectedWallDuplicate = useCallback(
(event: ReactMouseEvent<HTMLButtonElement>) => {
event.stopPropagation()
duplicateSelectedWall()
},
[duplicateSelectedWall],
)
const handleSelectedWallCurve = useCallback(
(event: ReactMouseEvent<HTMLButtonElement>) => {
event.stopPropagation()
const wall = selectedWallEntry?.wall
if (!(wall && canCurveSelectedWall)) {
return
}
sfxEmitter.emit('sfx:item-pick')
setCurvingWall(wall)
setSelection({ selectedIds: [] })
},
[canCurveSelectedWall, selectedWallEntry, setCurvingWall, setSelection],
)
const handleSelectedWallDelete = useCallback( const handleSelectedWallDelete = useCallback(
(event: ReactMouseEvent<HTMLButtonElement>) => { (event: ReactMouseEvent<HTMLButtonElement>) => {
event.stopPropagation() event.stopPropagation()
@@ -16020,9 +16159,17 @@ export function FloorplanPanel() {
site, site,
]) ])
const hasDuplicatableFloorplanSelection = Boolean( const hasDuplicatableFloorplanSelection = Boolean(
selectedItemEntry || selectedOpeningEntry || selectedStairEntry || selectedRoofEntry, selectedItemEntry ||
selectedOpeningEntry ||
selectedStairEntry ||
selectedRoofEntry ||
selectedWallEntry,
) )
const handleDuplicateFloorplanSelection = useCallback(() => { const handleDuplicateFloorplanSelection = useCallback(() => {
if (selectedWallEntry) {
duplicateSelectedWall()
return
}
if (selectedOpeningEntry) { if (selectedOpeningEntry) {
duplicateSelectedOpening() duplicateSelectedOpening()
return return
@@ -16039,6 +16186,7 @@ export function FloorplanPanel() {
duplicateSelectedRoof() duplicateSelectedRoof()
} }
}, [ }, [
duplicateSelectedWall,
duplicateSelectedItem, duplicateSelectedItem,
duplicateSelectedOpening, duplicateSelectedOpening,
duplicateSelectedRoof, duplicateSelectedRoof,
@@ -16047,6 +16195,7 @@ export function FloorplanPanel() {
selectedOpeningEntry, selectedOpeningEntry,
selectedRoofEntry, selectedRoofEntry,
selectedStairEntry, selectedStairEntry,
selectedWallEntry,
]) ])
const activeDraftAnchorPoint = const activeDraftAnchorPoint =
referenceScaleDraft?.start ?? referenceScaleDraft?.start ??
@@ -16173,7 +16322,9 @@ export function FloorplanPanel() {
}} }}
wall={{ wall={{
position: selectedWallActionMenuPosition, position: selectedWallActionMenuPosition,
onCurve: canCurveSelectedWall ? handleSelectedWallCurve : undefined,
onDelete: handleSelectedWallDelete, onDelete: handleSelectedWallDelete,
onDuplicate: handleSelectedWallDuplicate,
onMove: handleSelectedWallMove, onMove: handleSelectedWallMove,
}} }}
/> />
@@ -68,6 +68,7 @@ import { SiteEdgeLabels } from './site-edge-labels'
import { SnapshotCaptureOverlay } from './snapshot-capture-overlay' import { SnapshotCaptureOverlay } from './snapshot-capture-overlay'
import { type SnapshotCameraData, ThumbnailGenerator } from './thumbnail-generator' import { type SnapshotCameraData, ThumbnailGenerator } from './thumbnail-generator'
import { WallMeasurementLabel } from './wall-measurement-label' import { WallMeasurementLabel } from './wall-measurement-label'
import { WallMoveSideHandles } from './wall-move-side-handles'
const CAMERA_CONTROLS_HINT_DISMISSED_STORAGE_KEY = 'editor-camera-controls-hint-dismissed:v1' const CAMERA_CONTROLS_HINT_DISMISSED_STORAGE_KEY = 'editor-camera-controls-hint-dismissed:v1'
const DELETE_CURSOR_BADGE_COLOR = '#ef4444' const DELETE_CURSOR_BADGE_COLOR = '#ef4444'
@@ -587,6 +588,7 @@ const ViewerSceneContent = memo(function ViewerSceneContent({
<> <>
{!isFirstPersonMode && <SelectionManager />} {!isFirstPersonMode && <SelectionManager />}
{!(isVersionPreviewMode || isFirstPersonMode) && <BoxSelectTool />} {!(isVersionPreviewMode || isFirstPersonMode) && <BoxSelectTool />}
{!(isVersionPreviewMode || isFirstPersonMode) && <WallMoveSideHandles />}
{!(isVersionPreviewMode || isFirstPersonMode) && <FloatingActionMenu />} {!(isVersionPreviewMode || isFirstPersonMode) && <FloatingActionMenu />}
{!(isVersionPreviewMode || isFirstPersonMode) && <FloatingBuildingActionMenu />} {!(isVersionPreviewMode || isFirstPersonMode) && <FloatingBuildingActionMenu />}
{!isFirstPersonMode && <WallMeasurementLabel />} {!isFirstPersonMode && <WallMeasurementLabel />}
@@ -1636,6 +1636,7 @@ const EditorOutlinerSync = () => {
const previewSelectedIds = useViewer((s) => s.previewSelectedIds) const previewSelectedIds = useViewer((s) => s.previewSelectedIds)
const hoveredId = useViewer((s) => s.hoveredId) const hoveredId = useViewer((s) => s.hoveredId)
const outliner = useViewer((s) => s.outliner) const outliner = useViewer((s) => s.outliner)
const nodes = useScene((s) => s.nodes)
useEffect(() => { useEffect(() => {
let idsToHighlight: string[] = [] let idsToHighlight: string[] = []
@@ -1672,16 +1673,21 @@ const EditorOutlinerSync = () => {
// 2. Sync with the imperative outliner arrays (mutate in place to keep references) // 2. Sync with the imperative outliner arrays (mutate in place to keep references)
outliner.selectedObjects.length = 0 outliner.selectedObjects.length = 0
for (const id of idsToHighlight) { for (const id of idsToHighlight) {
if (!nodes[id as AnyNodeId]) continue
const obj = sceneRegistry.nodes.get(id) const obj = sceneRegistry.nodes.get(id)
if (obj?.parent) outliner.selectedObjects.push(obj) if (obj?.parent) outliner.selectedObjects.push(obj)
} }
outliner.hoveredObjects.length = 0 outliner.hoveredObjects.length = 0
if (hoveredId) { if (hoveredId) {
if (!nodes[hoveredId as AnyNodeId]) {
useViewer.setState({ hoveredId: null })
} else {
const obj = sceneRegistry.nodes.get(hoveredId) const obj = sceneRegistry.nodes.get(hoveredId)
if (obj?.parent) outliner.hoveredObjects.push(obj) if (obj?.parent) outliner.hoveredObjects.push(obj)
} }
}, [phase, previewSelectedIds, selection, hoveredId, outliner]) }
}, [phase, previewSelectedIds, selection, hoveredId, outliner, nodes])
return null return null
} }
@@ -3,6 +3,7 @@
import type { import type {
AnyNode, AnyNode,
CeilingNode, CeilingNode,
ColumnNode,
DoorNode, DoorNode,
ItemNode, ItemNode,
Point2D, Point2D,
@@ -46,6 +47,11 @@ type CeilingPolygonEntry = {
holes: Point2D[][] holes: Point2D[][]
} }
type ColumnPolygonEntry = {
column: ColumnNode
polygon: Point2D[]
}
type FloorplanRoofEntry = { type FloorplanRoofEntry = {
roof: RoofNode roof: RoofNode
segments: Array<{ segments: Array<{
@@ -72,6 +78,7 @@ type FloorplanStairEntry = {
type UseFloorplanHitTestingArgs = { type UseFloorplanHitTestingArgs = {
ceilingPolygons: CeilingPolygonEntry[] ceilingPolygons: CeilingPolygonEntry[]
columnPolygons: ColumnPolygonEntry[]
displaySlabPolygons: SlabPolygonEntry[] displaySlabPolygons: SlabPolygonEntry[]
displayWallPolygons: WallPolygonEntry[] displayWallPolygons: WallPolygonEntry[]
floorplanItemEntries: FloorplanItemEntry[] floorplanItemEntries: FloorplanItemEntry[]
@@ -88,6 +95,7 @@ type UseFloorplanHitTestingArgs = {
export function useFloorplanHitTesting({ export function useFloorplanHitTesting({
ceilingPolygons, ceilingPolygons,
columnPolygons,
displaySlabPolygons, displaySlabPolygons,
displayWallPolygons, displayWallPolygons,
floorplanItemEntries, floorplanItemEntries,
@@ -117,11 +125,13 @@ export function useFloorplanHitTesting({
slabs: displaySlabPolygons, slabs: displaySlabPolygons,
openingHitTolerance: floorplanOpeningHitTolerance, openingHitTolerance: floorplanOpeningHitTolerance,
wallHitTolerance: floorplanWallHitTolerance, wallHitTolerance: floorplanWallHitTolerance,
columns: columnPolygons,
getOpeningCenterLine, getOpeningCenterLine,
}) })
}, },
[ [
ceilingPolygons, ceilingPolygons,
columnPolygons,
displaySlabPolygons, displaySlabPolygons,
displayWallPolygons, displayWallPolygons,
floorplanItemEntries, floorplanItemEntries,
@@ -149,10 +159,12 @@ export function useFloorplanHitTesting({
openings: openingsPolygons, openings: openingsPolygons,
roofs: floorplanRoofEntries, roofs: floorplanRoofEntries,
slabs: displaySlabPolygons, slabs: displaySlabPolygons,
columns: columnPolygons,
stairs: floorplanStairEntries, stairs: floorplanStairEntries,
}), }),
[ [
ceilingPolygons, ceilingPolygons,
columnPolygons,
displaySlabPolygons, displaySlabPolygons,
displayWallPolygons, displayWallPolygons,
floorplanItemEntries, floorplanItemEntries,
@@ -0,0 +1,259 @@
'use client'
import {
type AnyNodeId,
DEFAULT_WALL_HEIGHT,
getWallThickness,
sceneRegistry,
useScene,
type WallNode,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { createPortal, type ThreeEvent } from '@react-three/fiber'
import { useEffect, useMemo, useState } from 'react'
import {
BufferGeometry,
ConeGeometry,
CylinderGeometry,
DoubleSide,
Float32BufferAttribute,
type Object3D,
} from 'three'
import { sfxEmitter } from '../../lib/sfx-bus'
import useEditor from '../../store/use-editor'
const HANDLE_OFFSET = 0.42
const HANDLE_MIN_OFFSET = 0.5
const HANDLE_MIN_HEIGHT = 0.62
const HANDLE_TOP_INSET = 0.08
const ARROW_COLOR = '#8381ed'
const ARROW_HOVER_COLOR = '#a5b4fc'
type WallMoveHandle = {
direction: [number, number]
key: string
position: [number, number, number]
rotationY: number
}
function createArrowHandleGeometry() {
const shaft = new CylinderGeometry(0.04, 0.064, 0.25, 36)
const head = new ConeGeometry(0.13, 0.3, 48)
shaft.rotateZ(-Math.PI / 2)
shaft.translate(-0.085, 0, 0)
head.rotateZ(-Math.PI / 2)
head.translate(0.17, 0, 0)
const positions: number[] = []
const normals: number[] = []
const uvs: number[] = []
for (const sourceGeometry of [shaft, head]) {
const geometry = sourceGeometry.index ? sourceGeometry.toNonIndexed() : sourceGeometry
const position = geometry.getAttribute('position')
const normal = geometry.getAttribute('normal')
const uv = geometry.getAttribute('uv')
for (let index = 0; index < position.count; index += 1) {
positions.push(position.getX(index), position.getY(index), position.getZ(index))
normals.push(normal.getX(index), normal.getY(index), normal.getZ(index))
uvs.push(uv?.getX(index) ?? 0, uv?.getY(index) ?? 0)
}
if (geometry !== sourceGeometry) {
geometry.dispose()
}
sourceGeometry.dispose()
}
const geometry = new BufferGeometry()
geometry.setAttribute('position', new Float32BufferAttribute(positions, 3))
geometry.setAttribute('normal', new Float32BufferAttribute(normals, 3))
geometry.setAttribute('uv', new Float32BufferAttribute(uvs, 2))
geometry.setAttribute('uv2', new Float32BufferAttribute([...uvs], 2))
geometry.computeVertexNormals()
geometry.computeBoundingSphere()
return geometry
}
export function WallMoveSideHandles() {
const selectedIds = useViewer((state) => state.selection.selectedIds)
const mode = useEditor((state) => state.mode)
const isFloorplanHovered = useEditor((state) => state.isFloorplanHovered)
const movingNode = useEditor((state) => state.movingNode)
const movingWallEndpoint = useEditor((state) => state.movingWallEndpoint)
const movingFenceEndpoint = useEditor((state) => state.movingFenceEndpoint)
const curvingWall = useEditor((state) => state.curvingWall)
const curvingFence = useEditor((state) => state.curvingFence)
const selectedId = selectedIds.length === 1 ? selectedIds[0] : null
const wall = useScene((state) => {
const node = selectedId ? state.nodes[selectedId as AnyNodeId] : null
return node?.type === 'wall' ? node : null
})
const shouldRender =
Boolean(wall) &&
!isFloorplanHovered &&
mode !== 'delete' &&
!movingNode &&
!movingWallEndpoint &&
!movingFenceEndpoint &&
!curvingWall &&
!curvingFence
if (!shouldRender || !wall) return null
return <WallMoveSideHandlesForWall wall={wall} />
}
function WallMoveSideHandlesForWall({ wall }: { wall: WallNode }) {
const [levelObject, setLevelObject] = useState<Object3D | null>(() =>
wall.parentId ? (sceneRegistry.nodes.get(wall.parentId) ?? null) : null,
)
useEffect(() => {
let frameId = 0
const resolveLevelObject = () => {
const nextLevelObject = wall.parentId
? (sceneRegistry.nodes.get(wall.parentId) ?? null)
: null
setLevelObject((currentLevelObject) => {
if (currentLevelObject === nextLevelObject) {
return currentLevelObject
}
return nextLevelObject
})
if (!nextLevelObject) {
frameId = window.requestAnimationFrame(resolveLevelObject)
}
}
resolveLevelObject()
return () => {
if (frameId) {
window.cancelAnimationFrame(frameId)
}
}
}, [wall.parentId])
const handles = useMemo(() => getWallMoveHandles(wall), [wall])
if (!levelObject || handles.length === 0) return null
return createPortal(
<group>
{handles.map((handle) => (
<WallMoveArrowHandle handle={handle} key={handle.key} wall={wall} />
))}
</group>,
levelObject,
)
}
function WallMoveArrowHandle({ wall, handle }: { wall: WallNode; handle: WallMoveHandle }) {
const [isHovered, setIsHovered] = useState(false)
const arrowGeometry = useMemo(() => createArrowHandleGeometry(), [])
useEffect(() => {
return () => {
if (document.body.style.cursor === 'grab' || document.body.style.cursor === 'grabbing') {
document.body.style.cursor = ''
}
}
}, [])
useEffect(() => () => arrowGeometry.dispose(), [arrowGeometry])
const activateWallMove = (event: ThreeEvent<PointerEvent>) => {
event.stopPropagation()
event.nativeEvent.preventDefault()
document.body.style.cursor = 'grabbing'
sfxEmitter.emit('sfx:item-pick')
useEditor.getState().setMovingNode(wall)
useEditor.getState().setMovingWallEndpoint(null)
useEditor.getState().setMovingFenceEndpoint(null)
useEditor.getState().setCurvingWall(null)
useEditor.getState().setCurvingFence(null)
useViewer.getState().setSelection({ selectedIds: [] })
}
return (
<group
position={handle.position}
rotation={[0, handle.rotationY, 0]}
scale={isHovered ? 1.12 : 1}
>
<mesh
frustumCulled={false}
onPointerDown={activateWallMove}
onPointerEnter={(event) => {
event.stopPropagation()
setIsHovered(true)
document.body.style.cursor = 'grab'
}}
onPointerLeave={(event) => {
event.stopPropagation()
setIsHovered(false)
if (document.body.style.cursor === 'grab') {
document.body.style.cursor = ''
}
}}
renderOrder={1002}
>
<primitive attach="geometry" object={arrowGeometry} />
<meshBasicMaterial
color={isHovered ? ARROW_HOVER_COLOR : ARROW_COLOR}
depthTest
depthWrite
opacity={1}
side={DoubleSide}
transparent={false}
/>
</mesh>
</group>
)
}
function getWallMoveHandles(wall: WallNode): WallMoveHandle[] {
const dx = wall.end[0] - wall.start[0]
const dz = wall.end[1] - wall.start[1]
const length = Math.hypot(dx, dz)
if (length < 1e-6) {
return []
}
const normal: [number, number] = [-dz / length, dx / length]
const midpoint: [number, number] = [
(wall.start[0] + wall.end[0]) / 2,
(wall.start[1] + wall.end[1]) / 2,
]
const wallHeight = wall.height ?? DEFAULT_WALL_HEIGHT
const handleHeight = Math.max(wallHeight - HANDLE_TOP_INSET, HANDLE_MIN_HEIGHT)
const offset = Math.max(getWallThickness(wall) / 2 + HANDLE_OFFSET, HANDLE_MIN_OFFSET)
return [
buildWallMoveHandle('front', midpoint, normal, offset, handleHeight),
buildWallMoveHandle('back', midpoint, [-normal[0], -normal[1]], offset, handleHeight),
]
}
function buildWallMoveHandle(
key: string,
midpoint: [number, number],
direction: [number, number],
offset: number,
height: number,
): WallMoveHandle {
return {
direction,
key,
position: [midpoint[0] + direction[0] * offset, height, midpoint[1] + direction[1] * offset],
rotationY: Math.atan2(-direction[1], direction[0]),
}
}
@@ -13,7 +13,6 @@ import {
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import type { Group } from 'three' import type { Group } from 'three'
import { sfxEmitter } from '../../../lib/sfx-bus' import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import { CursorSphere } from '../shared/cursor-sphere' import { CursorSphere } from '../shared/cursor-sphere'
const COLUMN_ICON = ( const COLUMN_ICON = (
@@ -70,8 +69,6 @@ export const ColumnTool: React.FC<ColumnToolProps> = ({ currentLevelId, onPlaced
useScene.getState().createNode(column, currentLevelId) useScene.getState().createNode(column, currentLevelId)
onPlaced?.(column.id) onPlaced?.(column.id)
sfxEmitter.emit('sfx:structure-build') sfxEmitter.emit('sfx:structure-build')
useEditor.getState().setTool(null)
useEditor.getState().setMode('select')
} }
emitter.on('grid:move', onGridMove) emitter.on('grid:move', onGridMove)
@@ -88,7 +85,7 @@ export const ColumnTool: React.FC<ColumnToolProps> = ({ currentLevelId, onPlaced
return ( return (
<CursorSphere <CursorSphere
color="#a78bfa" color="#a78bfa"
height={2.8} height={2.5}
ref={cursorRef} ref={cursorRef}
showTooltip showTooltip
tooltipContent={COLUMN_ICON} tooltipContent={COLUMN_ICON}
@@ -20,6 +20,9 @@ import {
export type FencePlanPoint = WallPlanPoint export type FencePlanPoint = WallPlanPoint
const FENCE_CORNER_SNAP_RADIUS = 0.28
const FENCE_SPAN_SNAP_RADIUS = 0.16
type SegmentNode = { type SegmentNode = {
start: FencePlanPoint start: FencePlanPoint
end: FencePlanPoint end: FencePlanPoint
@@ -57,46 +60,68 @@ function findFenceSnapTarget(
fences: FenceNode[], fences: FenceNode[],
ignoreFenceIds: string[] = [], ignoreFenceIds: string[] = [],
): FencePlanPoint | null { ): FencePlanPoint | null {
const radiusSquared = 0.35 ** 2 const cornerRadiusSquared = FENCE_CORNER_SNAP_RADIUS ** 2
const spanRadiusSquared = FENCE_SPAN_SNAP_RADIUS ** 2
const ignoredFenceIds = new Set(ignoreFenceIds) const ignoredFenceIds = new Set(ignoreFenceIds)
let bestTarget: FencePlanPoint | null = null let bestCornerTarget: FencePlanPoint | null = null
let bestDistanceSquared = Number.POSITIVE_INFINITY let bestCornerDistanceSquared = Number.POSITIVE_INFINITY
let bestSpanTarget: FencePlanPoint | null = null
let bestSpanDistanceSquared = Number.POSITIVE_INFINITY
for (const fence of fences) { for (const fence of fences) {
if (ignoredFenceIds.has(fence.id)) { if (ignoredFenceIds.has(fence.id)) {
continue continue
} }
const candidates: Array<FencePlanPoint | null> = [fence.start, fence.end] for (const candidate of [fence.start, fence.end]) {
if (isCurvedWall(fence)) { const candidateDistanceSquared = distanceSquared(point, candidate)
const sampleCount = Math.max(8, Math.ceil(getWallCurveLength(fence) / 0.3)) if (
for (let index = 0; index <= sampleCount; index += 1) { candidateDistanceSquared > cornerRadiusSquared ||
const frame = getWallCurveFrameAt(fence, index / sampleCount) candidateDistanceSquared >= bestCornerDistanceSquared
candidates.push([frame.point.x, frame.point.y]) ) {
} continue
} else {
candidates.push(projectPointOntoSegment(point, fence))
} }
for (const candidate of candidates) { bestCornerTarget = candidate
bestCornerDistanceSquared = candidateDistanceSquared
}
if (isCurvedWall(fence)) {
const sampleCount = Math.max(8, Math.ceil(getWallCurveLength(fence) / 0.3))
for (let index = 1; index < sampleCount; index += 1) {
const frame = getWallCurveFrameAt(fence, index / sampleCount)
const candidate: FencePlanPoint = [frame.point.x, frame.point.y]
const candidateDistanceSquared = distanceSquared(point, candidate)
if (
candidateDistanceSquared > spanRadiusSquared ||
candidateDistanceSquared >= bestSpanDistanceSquared
) {
continue
}
bestSpanTarget = candidate
bestSpanDistanceSquared = candidateDistanceSquared
}
} else {
const candidate = projectPointOntoSegment(point, fence)
if (!candidate) { if (!candidate) {
continue continue
} }
const candidateDistanceSquared = distanceSquared(point, candidate) const candidateDistanceSquared = distanceSquared(point, candidate)
if ( if (
candidateDistanceSquared > radiusSquared || candidateDistanceSquared > spanRadiusSquared ||
candidateDistanceSquared >= bestDistanceSquared candidateDistanceSquared >= bestSpanDistanceSquared
) { ) {
continue continue
} }
bestTarget = candidate bestSpanTarget = candidate
bestDistanceSquared = candidateDistanceSquared bestSpanDistanceSquared = candidateDistanceSquared
} }
} }
return bestTarget return bestCornerTarget ?? bestSpanTarget
} }
export function snapFenceDraftPoint(args: { export function snapFenceDraftPoint(args: {
@@ -25,8 +25,13 @@ import {
import { isWallLongEnough } from '../wall/wall-drafting' import { isWallLongEnough } from '../wall/wall-drafting'
import { type FencePlanPoint, snapFenceDraftPoint } from './fence-drafting' import { type FencePlanPoint, snapFenceDraftPoint } from './fence-drafting'
const LINKED_FENCE_ENDPOINT_EPSILON = 0.025
function samePoint(a: FencePlanPoint, b: FencePlanPoint) { function samePoint(a: FencePlanPoint, b: FencePlanPoint) {
return a[0] === b[0] && a[1] === b[1] return (
Math.abs(a[0] - b[0]) <= LINKED_FENCE_ENDPOINT_EPSILON &&
Math.abs(a[1] - b[1]) <= LINKED_FENCE_ENDPOINT_EPSILON
)
} }
type SegmentLike = { type SegmentLike = {
@@ -114,10 +119,9 @@ type LinkedFenceSnapshot = {
function getLinkedFenceSnapshots(args: { function getLinkedFenceSnapshots(args: {
fenceId: FenceNode['id'] fenceId: FenceNode['id']
fenceParentId: string | null fenceParentId: string | null
originalStart: FencePlanPoint linkedPoint: FencePlanPoint
originalEnd: FencePlanPoint
}) { }) {
const { fenceId, fenceParentId, originalStart, originalEnd } = args const { fenceId, fenceParentId, linkedPoint } = args
const { nodes } = useScene.getState() const { nodes } = useScene.getState()
const snapshots: LinkedFenceSnapshot[] = [] const snapshots: LinkedFenceSnapshot[] = []
@@ -130,14 +134,7 @@ function getLinkedFenceSnapshots(args: {
continue continue
} }
if ( if (!samePoint(node.start, linkedPoint) && !samePoint(node.end, linkedPoint)) {
!(
samePoint(node.start, originalStart) ||
samePoint(node.start, originalEnd) ||
samePoint(node.end, originalStart) ||
samePoint(node.end, originalEnd)
)
) {
continue continue
} }
@@ -154,24 +151,14 @@ function getLinkedFenceSnapshots(args: {
function getLinkedFenceUpdates( function getLinkedFenceUpdates(
linkedFences: LinkedFenceSnapshot[], linkedFences: LinkedFenceSnapshot[],
originalStart: FencePlanPoint, linkedPoint: FencePlanPoint,
originalEnd: FencePlanPoint, nextLinkedPoint: FencePlanPoint,
nextStart: FencePlanPoint,
nextEnd: FencePlanPoint,
) { ) {
return linkedFences.map((fence) => ({ return linkedFences.map((fence) => ({
id: fence.id, id: fence.id,
curveOffset: fence.curveOffset, curveOffset: fence.curveOffset,
start: samePoint(fence.start, originalStart) start: samePoint(fence.start, linkedPoint) ? nextLinkedPoint : fence.start,
? nextStart end: samePoint(fence.end, linkedPoint) ? nextLinkedPoint : fence.end,
: samePoint(fence.start, originalEnd)
? nextEnd
: fence.start,
end: samePoint(fence.end, originalStart)
? nextStart
: samePoint(fence.end, originalEnd)
? nextEnd
: fence.end,
})) }))
} }
@@ -183,6 +170,11 @@ export const MoveFenceEndpointTool: React.FC<{ target: MovingFenceEndpoint }> =
const nodeIdRef = useRef(target.fence.id) const nodeIdRef = useRef(target.fence.id)
const originalStartRef = useRef<FencePlanPoint>([...target.fence.start] as FencePlanPoint) const originalStartRef = useRef<FencePlanPoint>([...target.fence.start] as FencePlanPoint)
const originalEndRef = useRef<FencePlanPoint>([...target.fence.end] as FencePlanPoint) const originalEndRef = useRef<FencePlanPoint>([...target.fence.end] as FencePlanPoint)
const originalMovingPointRef = useRef<FencePlanPoint>(
target.endpoint === 'start'
? ([...target.fence.start] as FencePlanPoint)
: ([...target.fence.end] as FencePlanPoint),
)
const fixedPointRef = useRef<FencePlanPoint>( const fixedPointRef = useRef<FencePlanPoint>(
target.endpoint === 'start' target.endpoint === 'start'
? ([...target.fence.end] as FencePlanPoint) ? ([...target.fence.end] as FencePlanPoint)
@@ -192,8 +184,7 @@ export const MoveFenceEndpointTool: React.FC<{ target: MovingFenceEndpoint }> =
getLinkedFenceSnapshots({ getLinkedFenceSnapshots({
fenceId: target.fence.id, fenceId: target.fence.id,
fenceParentId: target.fence.parentId ?? null, fenceParentId: target.fence.parentId ?? null,
originalStart: target.fence.start, linkedPoint: target.endpoint === 'start' ? target.fence.start : target.fence.end,
originalEnd: target.fence.end,
}), }),
) )
const previewRef = useRef<{ start: FencePlanPoint; end: FencePlanPoint } | null>(null) const previewRef = useRef<{ start: FencePlanPoint; end: FencePlanPoint } | null>(null)
@@ -213,6 +204,7 @@ export const MoveFenceEndpointTool: React.FC<{ target: MovingFenceEndpoint }> =
const nodeId = nodeIdRef.current const nodeId = nodeIdRef.current
const originalStart = originalStartRef.current const originalStart = originalStartRef.current
const originalEnd = originalEndRef.current const originalEnd = originalEndRef.current
const originalMovingPoint = originalMovingPointRef.current
const fixedPoint = fixedPointRef.current const fixedPoint = fixedPointRef.current
const siblings = Object.values(useScene.getState().nodes) const siblings = Object.values(useScene.getState().nodes)
const levelWalls = siblings.filter( const levelWalls = siblings.filter(
@@ -246,13 +238,7 @@ export const MoveFenceEndpointTool: React.FC<{ target: MovingFenceEndpoint }> =
const nextEnd = target.endpoint === 'end' ? movingPoint : fixedPoint const nextEnd = target.endpoint === 'end' ? movingPoint : fixedPoint
const linkedUpdates = detachLinkedFences const linkedUpdates = detachLinkedFences
? [] ? []
: getLinkedFenceUpdates( : getLinkedFenceUpdates(linkedOriginalsRef.current, originalMovingPoint, movingPoint)
linkedOriginalsRef.current,
originalStart,
originalEnd,
nextStart,
nextEnd,
)
previewRef.current = { start: nextStart, end: nextEnd } previewRef.current = { start: nextStart, end: nextEnd }
setCursorLocalPos([movingPoint[0], 0, movingPoint[1]]) setCursorLocalPos([movingPoint[0], 0, movingPoint[1]])
setAngleLabel( setAngleLabel(
@@ -324,10 +310,8 @@ export const MoveFenceEndpointTool: React.FC<{ target: MovingFenceEndpoint }> =
? [] ? []
: getLinkedFenceUpdates( : getLinkedFenceUpdates(
linkedOriginalsRef.current, linkedOriginalsRef.current,
originalStart, originalMovingPoint,
originalEnd, target.endpoint === 'start' ? preview.start : preview.end,
preview.start,
preview.end,
)), )),
]) ])
pauseSceneHistory(useScene) pauseSceneHistory(useScene)
@@ -1,7 +1,10 @@
import '../../../three-types'
import { Icon } from '@iconify/react' import { Icon } from '@iconify/react'
import { import {
type AnyNodeId, type AnyNodeId,
type CeilingNode, type CeilingNode,
type ColumnNode,
emitter, emitter,
type GridEvent, type GridEvent,
type ItemNode, type ItemNode,
@@ -13,6 +16,7 @@ import {
type ZoneNode, type ZoneNode,
} from '@pascal-app/core' } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import type { ThreeElements } from '@react-three/fiber'
import { useThree } from '@react-three/fiber' import { useThree } from '@react-three/fiber'
import { useEffect, useRef } from 'react' import { useEffect, useRef } from 'react'
import { import {
@@ -34,6 +38,12 @@ import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor' import useEditor from '../../../store/use-editor'
import { CursorSphere } from '../shared/cursor-sphere' import { CursorSphere } from '../shared/cursor-sphere'
declare module 'react/jsx-runtime' {
namespace JSX {
interface IntrinsicElements extends ThreeElements {}
}
}
/** /**
* Module-level flag to prevent the SelectionManager from deselecting * Module-level flag to prevent the SelectionManager from deselecting
* on the grid:click that fires right after a box-select drag completes. * on the grid:click that fires right after a box-select drag completes.
@@ -250,6 +260,11 @@ function collectNodeIdsInBounds(bounds: Bounds): string[] {
if (objectBoundsIntersectsBounds(node.id, bounds)) { if (objectBoundsIntersectsBounds(node.id, bounds)) {
result.push(node.id) result.push(node.id)
} }
} else if (node.type === 'column') {
const column = node as ColumnNode
if (objectBoundsIntersectsBounds(column.id, bounds)) {
result.push(column.id)
}
} else if (node.type === 'item') { } else if (node.type === 'item') {
const item = node as ItemNode const item = node as ItemNode
if (item.asset.category === 'door' || item.asset.category === 'window') continue if (item.asset.category === 'door' || item.asset.category === 'window') continue
@@ -2,20 +2,35 @@
import { import {
type AnyNodeId, type AnyNodeId,
constrainWallMoveDeltaToAxis,
DEFAULT_WALL_HEIGHT,
detectSpacesForLevel,
emitter, emitter,
type GridEvent, type GridEvent,
getMaterialPresetByRef,
getPerpendicularWallMoveAxis,
pauseSceneHistory, pauseSceneHistory,
planAutoSlabsForLevel,
planWallMoveJunctions,
resolveMaterial,
resumeSceneHistory, resumeSceneHistory,
type SlabNode,
useScene, useScene,
type WallMoveAxis,
type WallMoveBridgePlan,
type WallMoveJunctionPlan,
type WallNode, type WallNode,
WallNode as WallSchema,
} from '@pascal-app/core' } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { BufferGeometry, DoubleSide, Float32BufferAttribute } from 'three'
import { markToolCancelConsumed } from '../../../hooks/use-keyboard' import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
import { EDITOR_LAYER } from '../../../lib/constants'
import { sfxEmitter } from '../../../lib/sfx-bus' import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor' import useEditor from '../../../store/use-editor'
import { CursorSphere } from '../shared/cursor-sphere' import { CursorSphere } from '../shared/cursor-sphere'
import { getWallGridStep, snapScalarToGrid } from './wall-drafting' import { getWallGridStep, isWallLongEnough, snapScalarToGrid } from './wall-drafting'
function rotateVector([x, z]: [number, number], angle: number): [number, number] { function rotateVector([x, z]: [number, number], angle: number): [number, number] {
const cos = Math.cos(angle) const cos = Math.cos(angle)
@@ -27,6 +42,10 @@ function samePoint(a: [number, number], b: [number, number]) {
return a[0] === b[0] && a[1] === b[1] return a[0] === b[0] && a[1] === b[1]
} }
function pointKey(point: [number, number]) {
return `${point[0]}:${point[1]}`
}
function stripWallIsNewMetadata(meta: WallNode['metadata']): WallNode['metadata'] { function stripWallIsNewMetadata(meta: WallNode['metadata']): WallNode['metadata'] {
if (!meta || typeof meta !== 'object' || Array.isArray(meta)) { if (!meta || typeof meta !== 'object' || Array.isArray(meta)) {
return meta return meta
@@ -37,10 +56,14 @@ function stripWallIsNewMetadata(meta: WallNode['metadata']): WallNode['metadata'
return nextMeta as WallNode['metadata'] return nextMeta as WallNode['metadata']
} }
type LinkedWallSnapshot = { type LinkedWallSnapshot = WallNode
id: WallNode['id']
type GhostWallPreview = {
id: string
start: [number, number] start: [number, number]
end: [number, number] end: [number, number]
color: string
height: number
} }
function getLinkedWallSnapshots(args: { function getLinkedWallSnapshots(args: {
@@ -51,32 +74,42 @@ function getLinkedWallSnapshots(args: {
}) { }) {
const { wallId, wallParentId, originalStart, originalEnd } = args const { wallId, wallParentId, originalStart, originalEnd } = args
const { nodes } = useScene.getState() const { nodes } = useScene.getState()
const snapshots: LinkedWallSnapshot[] = [] const walls = Object.values(nodes).filter(
(node): node is WallNode =>
for (const node of Object.values(nodes)) { node?.type === 'wall' && node.id !== wallId && (node.parentId ?? null) === wallParentId,
if (!(node?.type === 'wall' && node.id !== wallId)) {
continue
}
if ((node.parentId ?? null) !== wallParentId) {
continue
}
if (
!(
samePoint(node.start, originalStart) ||
samePoint(node.start, originalEnd) ||
samePoint(node.end, originalStart) ||
samePoint(node.end, originalEnd)
) )
) { const directlyLinkedWalls = walls.filter(
(wall) =>
samePoint(wall.start, originalStart) ||
samePoint(wall.start, originalEnd) ||
samePoint(wall.end, originalStart) ||
samePoint(wall.end, originalEnd),
)
const contextPoints = new Set([pointKey(originalStart), pointKey(originalEnd)])
for (const wall of directlyLinkedWalls) {
contextPoints.add(pointKey(wall.start))
contextPoints.add(pointKey(wall.end))
}
const snapshots: LinkedWallSnapshot[] = []
const seenWallIds = new Set<WallNode['id']>()
for (const node of walls) {
if (!contextPoints.has(pointKey(node.start)) && !contextPoints.has(pointKey(node.end))) {
continue continue
} }
if (seenWallIds.has(node.id)) {
continue
}
seenWallIds.add(node.id)
snapshots.push({ snapshots.push({
id: node.id, ...node,
start: [...node.start] as [number, number], start: [...node.start] as [number, number],
end: [...node.end] as [number, number], end: [...node.end] as [number, number],
children: [...(node.children ?? [])],
}) })
} }
@@ -84,25 +117,283 @@ function getLinkedWallSnapshots(args: {
} }
function getLinkedWallUpdates( function getLinkedWallUpdates(
linkedWalls: LinkedWallSnapshot[], linkedWalls: Array<{
wall: LinkedWallSnapshot
matchPoint?: [number, number]
targetPoint?: [number, number]
}>,
originalStart: [number, number], originalStart: [number, number],
originalEnd: [number, number], originalEnd: [number, number],
nextStart: [number, number], nextStart: [number, number],
nextEnd: [number, number], nextEnd: [number, number],
) { ) {
return linkedWalls.map((wall) => ({ return linkedWalls.map(({ wall, matchPoint, targetPoint }) => {
if (matchPoint && targetPoint) {
return {
id: wall.id,
start: samePoint(wall.start, matchPoint) ? targetPoint : wall.start,
end: samePoint(wall.end, matchPoint) ? targetPoint : wall.end,
}
}
const targetStart = targetPoint ?? nextStart
const targetEnd = targetPoint ?? nextEnd
return {
id: wall.id, id: wall.id,
start: samePoint(wall.start, originalStart) start: samePoint(wall.start, originalStart)
? nextStart ? targetStart
: samePoint(wall.start, originalEnd) : samePoint(wall.start, originalEnd)
? nextEnd ? targetEnd
: wall.start, : wall.start,
end: samePoint(wall.end, originalStart) end: samePoint(wall.end, originalStart)
? nextStart ? targetStart
: samePoint(wall.end, originalEnd) : samePoint(wall.end, originalEnd)
? nextEnd ? targetEnd
: wall.end, : wall.end,
})) }
})
}
function getPlannedLinkedWallUpdates(
plan: WallMoveJunctionPlan<LinkedWallSnapshot>,
originalStart: [number, number],
originalEnd: [number, number],
nextStart: [number, number],
nextEnd: [number, number],
) {
const movePlans = new Map<
WallNode['id'],
{ wall: LinkedWallSnapshot; matchPoint?: [number, number]; targetPoint?: [number, number] }
>()
for (const wall of plan.linkedWallsToMove) {
movePlans.set(wall.id, { wall })
}
for (const targetPlan of plan.linkedWallTargetPlans) {
movePlans.set(targetPlan.wall.id, {
wall: targetPlan.wall,
matchPoint: targetPlan.originalPoint,
targetPoint: targetPlan.targetPoint,
})
}
return getLinkedWallUpdates(
Array.from(movePlans.values()),
originalStart,
originalEnd,
nextStart,
nextEnd,
)
}
function wallSegmentExists(
walls: Array<Pick<WallNode, 'start' | 'end'>>,
start: [number, number],
end: [number, number],
) {
return walls.some(
(wall) =>
(samePoint(wall.start, start) && samePoint(wall.end, end)) ||
(samePoint(wall.start, end) && samePoint(wall.end, start)),
)
}
function getWallGhostColor(wall: WallNode) {
const presetColor =
getMaterialPresetByRef(wall.materialPreset)?.mapProperties.color ??
getMaterialPresetByRef(wall.interiorMaterialPreset)?.mapProperties.color ??
getMaterialPresetByRef(wall.exteriorMaterialPreset)?.mapProperties.color
if (presetColor) {
return presetColor
}
return resolveMaterial(wall.material ?? wall.interiorMaterial ?? wall.exteriorMaterial).color
}
function getWallsAfterUpdates(
nodes: ReturnType<typeof useScene.getState>['nodes'],
updates: Array<{ id: AnyNodeId; data: Partial<WallNode> }>,
) {
const updateById = new Map(updates.map((update) => [update.id, update.data]))
return Object.values(nodes)
.filter((node): node is WallNode => node?.type === 'wall')
.map((wall) => {
const update = updateById.get(wall.id as AnyNodeId)
return update ? ({ ...wall, ...update } as WallNode) : wall
})
}
function cloneSlabSnapshot(slab: SlabNode): SlabNode {
return {
...slab,
polygon: slab.polygon.map(([x, z]) => [x, z] as [number, number]),
holes: slab.holes.map((hole) => hole.map(([x, z]) => [x, z] as [number, number])),
holeMetadata: slab.holeMetadata.map((metadata) => ({ ...metadata })),
}
}
function getLevelSlabs(levelId: string, nodes: ReturnType<typeof useScene.getState>['nodes']) {
return Object.values(nodes).filter(
(entry): entry is SlabNode => entry?.type === 'slab' && (entry.parentId ?? null) === levelId,
)
}
function getLevelAutoSlabs(
levelId: string,
nodes: ReturnType<typeof useScene.getState>['nodes'],
) {
return getLevelSlabs(levelId, nodes).filter((slab) => slab.autoFromWalls)
}
function getLevelAutoSlabSnapshots(levelId: string) {
return getLevelAutoSlabs(levelId, useScene.getState().nodes).map(cloneSlabSnapshot)
}
function buildBridgeWallCreates(args: {
bridgePlans: Array<WallMoveBridgePlan<LinkedWallSnapshot>>
nextStart: [number, number]
nextEnd: [number, number]
existingWalls: WallNode[]
wallCount: number
}): Array<{ node: WallNode; parentId?: AnyNodeId }> {
const { bridgePlans, nextStart, nextEnd, existingWalls, wallCount } = args
const wallsForDuplicateCheck = [...existingWalls]
const creates: Array<{ node: WallNode; parentId?: AnyNodeId }> = []
for (const plan of bridgePlans) {
const nextPoint = plan.movedEndpoint === 'start' ? nextStart : nextEnd
if (!isWallLongEnough(plan.originalPoint, nextPoint)) {
continue
}
if (wallSegmentExists(wallsForDuplicateCheck, plan.originalPoint, nextPoint)) {
continue
}
const { id: _id, parentId: _parentId, children: _children, ...sourceWall } = plan.wall
const bridgeWall = WallSchema.parse({
...sourceWall,
name: `Wall ${wallCount + creates.length + 1}`,
start: plan.originalPoint,
end: nextPoint,
children: [],
metadata: stripWallIsNewMetadata(plan.wall.metadata),
})
creates.push({
node: bridgeWall,
parentId: (plan.wall.parentId ?? undefined) as AnyNodeId | undefined,
})
wallsForDuplicateCheck.push(bridgeWall)
}
return creates
}
function buildBridgeWallPreviews(args: {
bridgePlans: Array<WallMoveBridgePlan<LinkedWallSnapshot>>
nextStart: [number, number]
nextEnd: [number, number]
existingWalls: WallNode[]
}): Array<{ ghost: GhostWallPreview; wall: WallNode }> {
const { bridgePlans, nextStart, nextEnd, existingWalls } = args
const wallsForDuplicateCheck: Array<Pick<WallNode, 'start' | 'end'>> = [...existingWalls]
const previews: Array<{ ghost: GhostWallPreview; wall: WallNode }> = []
for (const plan of bridgePlans) {
const nextPoint = plan.movedEndpoint === 'start' ? nextStart : nextEnd
if (!isWallLongEnough(plan.originalPoint, nextPoint)) {
continue
}
if (wallSegmentExists(wallsForDuplicateCheck, plan.originalPoint, nextPoint)) {
continue
}
const { id: _id, children: _children, ...sourceWall } = plan.wall
const wall = WallSchema.parse({
...sourceWall,
name: 'Wall Preview',
start: plan.originalPoint,
end: nextPoint,
children: [],
metadata: stripWallIsNewMetadata(plan.wall.metadata),
})
const ghost = {
id: `${plan.wall.id}:${plan.movedEndpoint}:${previews.length}`,
start: [...plan.originalPoint] as [number, number],
end: [...nextPoint] as [number, number],
color: getWallGhostColor(plan.wall),
height: plan.wall.height ?? DEFAULT_WALL_HEIGHT,
}
previews.push({ ghost, wall })
wallsForDuplicateCheck.push(wall)
}
return previews
}
function setPreviewGeometryAttributes(
geometry: BufferGeometry,
positions: number[],
normals: number[],
uvs: number[],
) {
geometry.setAttribute('position', new Float32BufferAttribute(positions, 3))
geometry.setAttribute('normal', new Float32BufferAttribute(normals, 3))
geometry.setAttribute('uv', new Float32BufferAttribute(uvs, 2))
geometry.setAttribute('uv2', new Float32BufferAttribute([...uvs], 2))
}
function createWallPreviewGeometry(length: number, height: number) {
const geometry = new BufferGeometry()
setPreviewGeometryAttributes(
geometry,
[0, 0, 0, length, 0, 0, length, height, 0, 0, height, 0],
[0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1],
[0, 0, 1, 0, 1, 1, 0, 1],
)
geometry.setIndex([0, 1, 2, 0, 2, 3])
geometry.computeBoundingSphere()
return geometry
}
function GhostWallPreviewMesh({ preview }: { preview: GhostWallPreview }) {
const dx = preview.end[0] - preview.start[0]
const dz = preview.end[1] - preview.start[1]
const length = Math.hypot(dx, dz)
const angle = -Math.atan2(dz, dx)
const geometry = useMemo(() => {
return length < 0.01 ? null : createWallPreviewGeometry(length, preview.height)
}, [length, preview.height])
useEffect(() => () => geometry?.dispose(), [geometry])
if (!geometry) {
return null
}
return (
<group position={[preview.start[0], 0.02, preview.start[1]]} rotation={[0, angle, 0]}>
<mesh frustumCulled={false} layers={EDITOR_LAYER} renderOrder={2}>
<primitive attach="geometry" object={geometry} />
<meshBasicMaterial
color={preview.color}
depthTest={false}
depthWrite={false}
opacity={0.32}
side={DoubleSide}
transparent
/>
</mesh>
</group>
)
} }
export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => { export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
@@ -123,7 +414,10 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
(node.end[0] - node.start[0]) / 2, (node.end[0] - node.start[0]) / 2,
(node.end[1] - node.start[1]) / 2, (node.end[1] - node.start[1]) / 2,
]) ])
const linkedOriginalsRef = useRef( const moveAxisRef = useRef<WallMoveAxis | null>(
getPerpendicularWallMoveAxis(node.start, node.end),
)
const linkedOriginalsRef = useRef<LinkedWallSnapshot[]>(
isNew isNew
? [] ? []
: getLinkedWallSnapshots({ : getLinkedWallSnapshots({
@@ -133,6 +427,9 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
originalEnd: node.end, originalEnd: node.end,
}), }),
) )
const originalAutoSlabsRef = useRef<SlabNode[]>(
node.parentId ? getLevelAutoSlabSnapshots(node.parentId) : [],
)
const dragAnchorRef = useRef<[number, number] | null>(null) const dragAnchorRef = useRef<[number, number] | null>(null)
const nodeIdRef = useRef(node.id) const nodeIdRef = useRef(node.id)
const previewRef = useRef<{ start: [number, number]; end: [number, number] } | null>(null) const previewRef = useRef<{ start: [number, number]; end: [number, number] } | null>(null)
@@ -144,6 +441,7 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
const centerZ = (node.start[1] + node.end[1]) / 2 const centerZ = (node.start[1] + node.end[1]) / 2
return [centerX, 0, centerZ] return [centerX, 0, centerZ]
}) })
const [ghostWallPreviews, setGhostWallPreviews] = useState<GhostWallPreview[]>([])
const exitMoveMode = useCallback(() => { const exitMoveMode = useCallback(() => {
useEditor.getState().setMovingNode(null) useEditor.getState().setMovingNode(null)
@@ -155,9 +453,11 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
const originalEnd = originalEndRef.current const originalEnd = originalEndRef.current
const originalCenter = originalCenterRef.current const originalCenter = originalCenterRef.current
const originalHalfVector = originalHalfVectorRef.current const originalHalfVector = originalHalfVectorRef.current
const levelId = node.parentId ?? null
const originalAutoSlabs = originalAutoSlabsRef.current
pauseSceneHistory(useScene) pauseSceneHistory(useScene)
let wasCommitted = false let shouldRestoreOnCleanup = true
const applyNodePreview = ( const applyNodePreview = (
updates: Array<{ id: WallNode['id']; start: [number, number]; end: [number, number] }>, updates: Array<{ id: WallNode['id']; start: [number, number]; end: [number, number] }>,
@@ -173,6 +473,72 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
} }
} }
const applyLiveAutoSlabPreview = (walls: WallNode[]) => {
if (!levelId) {
return
}
const levelWalls = walls.filter((wall) => (wall.parentId ?? null) === levelId)
const sceneState = useScene.getState()
const { roomPolygons } = detectSpacesForLevel(levelId, levelWalls)
const slabPlan = planAutoSlabsForLevel(roomPolygons, getLevelSlabs(levelId, sceneState.nodes))
if (
slabPlan.create.length === 0 &&
slabPlan.update.length === 0 &&
slabPlan.delete.length === 0
) {
return
}
sceneState.applyNodeChanges({
update: slabPlan.update.map((entry) => ({
id: entry.id as AnyNodeId,
data: entry.data,
})),
create: slabPlan.create.map((slab) => ({
node: slab,
parentId: levelId as AnyNodeId,
})),
delete: slabPlan.delete.map((id) => id as AnyNodeId),
})
}
const restoreAutoSlabPreview = () => {
if (!levelId) {
return
}
const sceneState = useScene.getState()
const originalIds = new Set(originalAutoSlabs.map((slab) => slab.id))
const currentAutoSlabs = getLevelAutoSlabs(levelId, sceneState.nodes)
const update = originalAutoSlabs
.filter((slab) => sceneState.nodes[slab.id as AnyNodeId])
.map((slab) => ({
id: slab.id as AnyNodeId,
data: cloneSlabSnapshot(slab),
}))
const create = originalAutoSlabs
.filter((slab) => !sceneState.nodes[slab.id as AnyNodeId])
.map((slab) => ({
node: cloneSlabSnapshot(slab),
parentId: levelId as AnyNodeId,
}))
const deleteIds = currentAutoSlabs
.filter((slab) => !originalIds.has(slab.id))
.map((slab) => slab.id as AnyNodeId)
if (update.length === 0 && create.length === 0 && deleteIds.length === 0) {
return
}
sceneState.applyNodeChanges({
update,
create,
delete: deleteIds,
})
}
const buildWallFromCenter = (center: [number, number]) => { const buildWallFromCenter = (center: [number, number]) => {
const rotatedHalf = rotateVector(originalHalfVector, pendingRotationRef.current) const rotatedHalf = rotateVector(originalHalfVector, pendingRotationRef.current)
const nextStart: [number, number] = [center[0] - rotatedHalf[0], center[1] - rotatedHalf[1]] const nextStart: [number, number] = [center[0] - rotatedHalf[0], center[1] - rotatedHalf[1]]
@@ -180,28 +546,77 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
return { start: nextStart, end: nextEnd } return { start: nextStart, end: nextEnd }
} }
const applyPreview = (nextStart: [number, number], nextEnd: [number, number]) => { const getMovePlan = (nextStart: [number, number], nextEnd: [number, number]) =>
previewRef.current = { start: nextStart, end: nextEnd } planWallMoveJunctions(
const centerX = (nextStart[0] + nextEnd[0]) / 2
const centerZ = (nextStart[1] + nextEnd[1]) / 2
setCursorLocalPos([centerX, 0, centerZ])
applyNodePreview([
{ id: nodeId, start: nextStart, end: nextEnd },
...getLinkedWallUpdates(
linkedOriginalsRef.current, linkedOriginalsRef.current,
originalStart, originalStart,
originalEnd, originalEnd,
nextStart, nextStart,
nextEnd, nextEnd,
), )
const getLinkedPreviewUpdates = (
plan: WallMoveJunctionPlan<LinkedWallSnapshot>,
nextStart: [number, number],
nextEnd: [number, number],
) => {
const movedUpdates = getPlannedLinkedWallUpdates(
plan,
originalStart,
originalEnd,
nextStart,
nextEnd,
)
const movedById = new Map(movedUpdates.map((entry) => [entry.id, entry]))
return linkedOriginalsRef.current.map(
(wall) => movedById.get(wall.id) ?? { id: wall.id, start: wall.start, end: wall.end },
)
}
const applyPreview = (nextStart: [number, number], nextEnd: [number, number]) => {
previewRef.current = { start: nextStart, end: nextEnd }
const centerX = (nextStart[0] + nextEnd[0]) / 2
const centerZ = (nextStart[1] + nextEnd[1]) / 2
setCursorLocalPos([centerX, 0, centerZ])
const previewPlan = getMovePlan(nextStart, nextEnd)
const previewUpdates = [
{ id: nodeId, start: nextStart, end: nextEnd },
...getLinkedPreviewUpdates(previewPlan, nextStart, nextEnd),
]
const previewCollapsedWallIds = new Set([
...previewUpdates
.filter((entry) => entry.id !== nodeId && !isWallLongEnough(entry.start, entry.end))
.map((entry) => entry.id as AnyNodeId),
...previewPlan.wallsToDelete.map((wall) => wall.id as AnyNodeId),
]) ])
const previewSceneWalls = getWallsAfterUpdates(
useScene.getState().nodes,
previewUpdates.map((entry) => ({
id: entry.id as AnyNodeId,
data: { start: entry.start, end: entry.end },
})),
).filter((wall) => !previewCollapsedWallIds.has(wall.id as AnyNodeId))
const bridgePreviews = buildBridgeWallPreviews({
bridgePlans: previewPlan.bridgePlans,
nextStart,
nextEnd,
existingWalls: previewSceneWalls,
})
const nextGhostWalls = bridgePreviews.map((preview) => preview.ghost)
const virtualBridgeWalls = bridgePreviews.map((preview) => preview.wall)
setGhostWallPreviews(nextGhostWalls)
applyNodePreview(previewUpdates)
applyLiveAutoSlabPreview([...previewSceneWalls, ...virtualBridgeWalls])
} }
const restoreOriginal = () => { const restoreOriginal = () => {
setGhostWallPreviews([])
applyNodePreview([ applyNodePreview([
{ id: nodeId, start: originalStart, end: originalEnd }, { id: nodeId, start: originalStart, end: originalEnd },
...linkedOriginalsRef.current, ...linkedOriginalsRef.current,
]) ])
restoreAutoSlabPreview()
} }
const onGridMove = (event: GridEvent) => { const onGridMove = (event: GridEvent) => {
@@ -211,19 +626,24 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
const localX = shiftPressedRef.current ? rawX : snapScalarToGrid(rawX, snapStep) const localX = shiftPressedRef.current ? rawX : snapScalarToGrid(rawX, snapStep)
const localZ = shiftPressedRef.current ? rawZ : snapScalarToGrid(rawZ, snapStep) const localZ = shiftPressedRef.current ? rawZ : snapScalarToGrid(rawZ, snapStep)
if (
previousGridPosRef.current &&
(localX !== previousGridPosRef.current[0] || localZ !== previousGridPosRef.current[1])
) {
sfxEmitter.emit('sfx:grid-snap')
}
previousGridPosRef.current = [localX, localZ]
const anchor = dragAnchorRef.current ?? [localX, localZ] const anchor = dragAnchorRef.current ?? [localX, localZ]
dragAnchorRef.current = anchor dragAnchorRef.current = anchor
const deltaX = localX - anchor[0] const [deltaX, deltaZ] = constrainWallMoveDeltaToAxis(
const deltaZ = localZ - anchor[1] localX - anchor[0],
localZ - anchor[1],
moveAxisRef.current,
)
const constrainedGridPos: [number, number] = [anchor[0] + deltaX, anchor[1] + deltaZ]
if (
previousGridPosRef.current &&
(constrainedGridPos[0] !== previousGridPosRef.current[0] ||
constrainedGridPos[1] !== previousGridPosRef.current[1])
) {
sfxEmitter.emit('sfx:grid-snap')
}
previousGridPosRef.current = constrainedGridPos
const nextCenter: [number, number] = [originalCenter[0] + deltaX, originalCenter[1] + deltaZ] const nextCenter: [number, number] = [originalCenter[0] + deltaX, originalCenter[1] + deltaZ]
const nextWall = buildWallFromCenter(nextCenter) const nextWall = buildWallFromCenter(nextCenter)
@@ -238,16 +658,32 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
const preview = previewRef.current ?? { start: originalStart, end: originalEnd } const preview = previewRef.current ?? { start: originalStart, end: originalEnd }
wasCommitted = true shouldRestoreOnCleanup = false
// Restore original baseline while paused so the next resume+update // Restore original baseline while paused so the next resume+update
// registers as a single tracked change (undo reverts to original). // registers as a single tracked change (undo reverts to original).
setGhostWallPreviews([])
applyNodePreview([ applyNodePreview([
{ id: nodeId, start: originalStart, end: originalEnd }, { id: nodeId, start: originalStart, end: originalEnd },
...linkedOriginalsRef.current, ...linkedOriginalsRef.current,
]) ])
restoreAutoSlabPreview()
resumeSceneHistory(useScene) resumeSceneHistory(useScene)
const commitPlan = getMovePlan(preview.start, preview.end)
const linkedWallUpdates = getPlannedLinkedWallUpdates(
commitPlan,
originalStart,
originalEnd,
preview.start,
preview.end,
)
const collapsedLinkedWallIds = new Set([
...linkedWallUpdates
.filter((entry) => !isWallLongEnough(entry.start, entry.end))
.map((entry) => entry.id as AnyNodeId),
...commitPlan.wallsToDelete.map((wall) => wall.id as AnyNodeId),
])
const commitUpdates = [ const commitUpdates = [
{ {
@@ -260,21 +696,29 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
} }
: { start: preview.start, end: preview.end }, : { start: preview.start, end: preview.end },
}, },
...getLinkedWallUpdates( ...linkedWallUpdates
linkedOriginalsRef.current, .filter((entry) => !collapsedLinkedWallIds.has(entry.id as AnyNodeId))
originalStart, .map((entry) => ({
originalEnd,
preview.start,
preview.end,
).map((entry) => ({
id: entry.id as AnyNodeId, id: entry.id as AnyNodeId,
data: { start: entry.start, end: entry.end }, data: { start: entry.start, end: entry.end },
})), })),
] ]
useScene.getState().updateNodes(commitUpdates) const sceneState = useScene.getState()
for (const { id } of commitUpdates) { const existingWalls = getWallsAfterUpdates(sceneState.nodes, commitUpdates).filter(
useScene.getState().markDirty(id) (wall) => !collapsedLinkedWallIds.has(wall.id as AnyNodeId),
} )
const bridgeCreates = buildBridgeWallCreates({
bridgePlans: commitPlan.bridgePlans,
nextStart: preview.start,
nextEnd: preview.end,
existingWalls,
wallCount: Object.values(sceneState.nodes).filter((entry) => entry?.type === 'wall').length,
})
sceneState.applyNodeChanges({
update: commitUpdates,
create: bridgeCreates,
delete: Array.from(collapsedLinkedWallIds),
})
pauseSceneHistory(useScene) pauseSceneHistory(useScene)
@@ -313,6 +757,7 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
(preview.start[1] + preview.end[1]) / 2, (preview.start[1] + preview.end[1]) / 2,
] ]
const nextWall = buildWallFromCenter(currentCenter) const nextWall = buildWallFromCenter(currentCenter)
moveAxisRef.current = getPerpendicularWallMoveAxis(nextWall.start, nextWall.end)
applyPreview(nextWall.start, nextWall.end) applyPreview(nextWall.start, nextWall.end)
} }
@@ -323,6 +768,7 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
} }
const onCancel = () => { const onCancel = () => {
shouldRestoreOnCleanup = false
restoreOriginal() restoreOriginal()
useViewer.getState().setSelection({ selectedIds: [nodeId] }) useViewer.getState().setSelection({ selectedIds: [nodeId] })
resumeSceneHistory(useScene) resumeSceneHistory(useScene)
@@ -337,7 +783,7 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
window.addEventListener('keyup', onKeyUp) window.addEventListener('keyup', onKeyUp)
return () => { return () => {
if (!wasCommitted) { if (shouldRestoreOnCleanup) {
restoreOriginal() restoreOriginal()
} }
shiftPressedRef.current = false shiftPressedRef.current = false
@@ -348,11 +794,14 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
window.removeEventListener('keydown', onKeyDown) window.removeEventListener('keydown', onKeyDown)
window.removeEventListener('keyup', onKeyUp) window.removeEventListener('keyup', onKeyUp)
} }
}, [exitMoveMode, isNew, node.metadata]) }, [exitMoveMode, isNew, node.metadata, node.parentId])
return ( return (
<group> <group>
<CursorSphere position={cursorLocalPos} showTooltip={false} /> <CursorSphere position={cursorLocalPos} showTooltip={false} />
{ghostWallPreviews.map((preview) => (
<GhostWallPreviewMesh key={preview.id} preview={preview} />
))}
</group> </group>
) )
} }
@@ -26,7 +26,7 @@ import {
useScene, useScene,
} from '@pascal-app/core' } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { Copy, GripVertical, MoreVertical, Plus, Trash2 } from 'lucide-react' import { ClipboardPaste, Copy, GripVertical, MoreVertical, Plus, Trash2 } from 'lucide-react'
import { import {
type ButtonHTMLAttributes, type ButtonHTMLAttributes,
type CSSProperties, type CSSProperties,
@@ -34,6 +34,7 @@ import {
useEffect, useEffect,
useRef, useRef,
useState, useState,
useSyncExternalStore,
} from 'react' } from 'react'
import { useShallow } from 'zustand/react/shallow' import { useShallow } from 'zustand/react/shallow'
import { import {
@@ -41,6 +42,12 @@ import {
type LevelDuplicatePreset, type LevelDuplicatePreset,
} from '../../lib/level-duplication' } from '../../lib/level-duplication'
import { deleteLevelWithFallbackSelection } from '../../lib/level-selection' import { deleteLevelWithFallbackSelection } from '../../lib/level-selection'
import {
getEditorClipboardSnapshot,
pasteEditorClipboardToLevel,
subscribeEditorClipboard,
} from '../../lib/scene-clipboard'
import { sfxEmitter } from '../../lib/sfx-bus'
import { cn } from '../../lib/utils' import { cn } from '../../lib/utils'
import { LevelDuplicateDialog } from './level-duplicate-dialog' import { LevelDuplicateDialog } from './level-duplicate-dialog'
import { import {
@@ -126,6 +133,7 @@ function LevelRow({
dragHandleRef, dragHandleRef,
onSelect, onSelect,
onDuplicate, onDuplicate,
onPaste,
onRequestDelete, onRequestDelete,
}: { }: {
level: LevelNode level: LevelNode
@@ -135,6 +143,7 @@ function LevelRow({
dragHandleRef?: (element: HTMLButtonElement | null) => void dragHandleRef?: (element: HTMLButtonElement | null) => void
onSelect: () => void onSelect: () => void
onDuplicate: (preset?: LevelDuplicatePreset) => void onDuplicate: (preset?: LevelDuplicatePreset) => void
onPaste?: () => void
onRequestDelete: () => void onRequestDelete: () => void
}) { }) {
const [duplicateDialogOpen, setDuplicateDialogOpen] = useState(false) const [duplicateDialogOpen, setDuplicateDialogOpen] = useState(false)
@@ -223,6 +232,19 @@ function LevelRow({
<Copy className="h-3 w-3" /> <Copy className="h-3 w-3" />
Duplicate with options... Duplicate with options...
</button> </button>
{onPaste && (
<button
className="flex w-full items-center gap-2 rounded-md px-2.5 py-1.5 text-left text-muted-foreground text-xs transition-colors hover:bg-white/10 hover:text-foreground"
onClick={(e) => {
e.stopPropagation()
onPaste()
}}
type="button"
>
<ClipboardPaste className="h-3 w-3" />
Paste copied selection
</button>
)}
<button <button
className="flex w-full items-center gap-2 rounded-md px-2.5 py-1.5 text-left text-muted-foreground text-xs transition-colors hover:bg-white/10 hover:text-red-400" className="flex w-full items-center gap-2 rounded-md px-2.5 py-1.5 text-left text-muted-foreground text-xs transition-colors hover:bg-white/10 hover:text-red-400"
onClick={(e) => { onClick={(e) => {
@@ -256,12 +278,14 @@ function SortableLevelRow({
isSelected, isSelected,
onSelect, onSelect,
onDuplicate, onDuplicate,
onPaste,
onRequestDelete, onRequestDelete,
}: { }: {
level: LevelNode level: LevelNode
isSelected: boolean isSelected: boolean
onSelect: () => void onSelect: () => void
onDuplicate: (preset?: LevelDuplicatePreset) => void onDuplicate: (preset?: LevelDuplicatePreset) => void
onPaste?: () => void
onRequestDelete: () => void onRequestDelete: () => void
}) { }) {
const { const {
@@ -291,6 +315,7 @@ function SortableLevelRow({
isSelected={isSelected} isSelected={isSelected}
level={level} level={level}
onDuplicate={onDuplicate} onDuplicate={onDuplicate}
onPaste={onPaste}
onRequestDelete={onRequestDelete} onRequestDelete={onRequestDelete}
onSelect={onSelect} onSelect={onSelect}
/> />
@@ -310,6 +335,11 @@ export function FloatingLevelSelector() {
const [deletingLevel, setDeletingLevel] = useState<LevelNode | null>(null) const [deletingLevel, setDeletingLevel] = useState<LevelNode | null>(null)
const [draggingLevelId, setDraggingLevelId] = useState<string | null>(null) const [draggingLevelId, setDraggingLevelId] = useState<string | null>(null)
const clipboardSnapshot = useSyncExternalStore(
subscribeEditorClipboard,
getEditorClipboardSnapshot,
getEditorClipboardSnapshot,
)
const sensors = useSensors( const sensors = useSensors(
useSensor(PointerSensor, { useSensor(PointerSensor, {
activationConstraint: { distance: 4 }, activationConstraint: { distance: 4 },
@@ -424,6 +454,13 @@ export function FloatingLevelSelector() {
[createNodes, levels, resolvedBuildingId, setSelection, updateNodes], [createNodes, levels, resolvedBuildingId, setSelection, updateNodes],
) )
const handlePasteToLevel = useCallback((level: LevelNode) => {
const result = pasteEditorClipboardToLevel(level.id)
if (result?.pastedIds.length) {
sfxEmitter.emit('sfx:item-place')
}
}, [])
const handleDragStart = useCallback((event: DragStartEvent) => { const handleDragStart = useCallback((event: DragStartEvent) => {
setDraggingLevelId(String(event.active.id)) setDraggingLevelId(String(event.active.id))
}, []) }, [])
@@ -523,6 +560,9 @@ export function FloatingLevelSelector() {
isSelected={isSelected} isSelected={isSelected}
level={level} level={level}
onDuplicate={(preset) => handleDuplicateLevel(level, preset)} onDuplicate={(preset) => handleDuplicateLevel(level, preset)}
onPaste={
clipboardSnapshot ? () => handlePasteToLevel(level) : undefined
}
onRequestDelete={() => setDeletingLevel(level)} onRequestDelete={() => setDeletingLevel(level)}
onSelect={() => onSelect={() =>
setSelection( setSelection(
@@ -11,10 +11,12 @@ import { useViewer } from '@pascal-app/viewer'
import { Move, Trash2 } from 'lucide-react' import { Move, Trash2 } from 'lucide-react'
import { useCallback } from 'react' import { useCallback } from 'react'
import { sfxEmitter } from '../../../lib/sfx-bus' import { sfxEmitter } from '../../../lib/sfx-bus'
import { cn } from '../../../lib/utils'
import useEditor from '../../../store/use-editor' import useEditor from '../../../store/use-editor'
import { ActionButton, ActionGroup } from '../controls/action-button' import { ActionButton, ActionGroup } from '../controls/action-button'
import { PanelSection } from '../controls/panel-section' import { PanelSection } from '../controls/panel-section'
import { SliderControl } from '../controls/slider-control' import { SliderControl } from '../controls/slider-control'
import { ToggleControl } from '../controls/toggle-control'
import { PanelWrapper } from './panel-wrapper' import { PanelWrapper } from './panel-wrapper'
const SELECT_CLASS = const SELECT_CLASS =
@@ -77,6 +79,20 @@ const COLUMN_PROPORTION_OPTIONS = Object.entries(COLUMN_PROPORTION_PRESETS).map(
}), }),
) )
const SUPPORT_STYLE_OPTIONS: Array<{ label: string; value: ColumnNode['supportStyle'] }> = [
{ label: 'Vertical', value: 'vertical' },
{ label: 'A-Frame', value: 'a-frame' },
{ label: 'Y Support', value: 'y-frame' },
{ label: 'V Support', value: 'v-frame' },
{ label: 'X Brace', value: 'x-brace' },
{ label: 'K Brace', value: 'k-brace' },
{ label: 'Single Strut', value: 'single-strut' },
{ label: 'Tripod', value: 'tripod' },
{ label: 'Trestle', value: 'trestle' },
{ label: 'Portal Frame', value: 'portal-frame' },
{ label: 'Box Frame', value: 'box-frame' },
]
function clamp(value: number, min: number, max: number) { function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value)) return Math.min(max, Math.max(min, value))
} }
@@ -85,6 +101,7 @@ function presetUpdates(presetId: ColumnPresetId): Partial<ColumnNode> {
const { label, ...preset } = COLUMN_PRESETS[presetId] const { label, ...preset } = COLUMN_PRESETS[presetId]
return { return {
name: label, name: label,
supportStyle: 'supportStyle' in preset ? preset.supportStyle : 'vertical',
...preset, ...preset,
} }
} }
@@ -201,6 +218,18 @@ export function ColumnPanel() {
if (!(node && node.type === 'column' && selectedId && selectedCount === 1)) return null if (!(node && node.type === 'column' && selectedId && selectedCount === 1)) return null
const shaftProfile = node.shaftProfile ?? 'straight' const shaftProfile = node.shaftProfile ?? 'straight'
const supportStyle = node.supportStyle ?? 'vertical'
const isBraceSupport =
supportStyle === 'a-frame' ||
supportStyle === 'y-frame' ||
supportStyle === 'v-frame' ||
supportStyle === 'x-brace' ||
supportStyle === 'k-brace' ||
supportStyle === 'single-strut' ||
supportStyle === 'tripod' ||
supportStyle === 'trestle' ||
supportStyle === 'portal-frame' ||
supportStyle === 'box-frame'
return ( return (
<PanelWrapper <PanelWrapper
@@ -228,6 +257,64 @@ export function ColumnPanel() {
</PanelSection> </PanelSection>
<PanelSection title="Shape"> <PanelSection title="Shape">
<div className="grid grid-cols-2 gap-1.5 px-1 pt-1">
{SUPPORT_STYLE_OPTIONS.map((option) => {
const isSelected = supportStyle === option.value
return (
<button
className={cn(
'flex min-h-12 items-center rounded-lg border px-2.5 text-left text-xs transition-colors',
isSelected
? 'border-orange-400/60 bg-orange-400/10 text-foreground'
: 'border-border/50 bg-[#2C2C2E] text-muted-foreground hover:bg-[#3e3e3e] hover:text-foreground',
)}
key={option.value}
onClick={() =>
handleUpdate({
supportStyle: option.value,
...(option.value !== 'vertical'
? {
crossSection: 'rectangular',
width: node.braceWidth ?? node.width,
depth: node.braceDepth ?? node.depth,
baseStyle: 'none',
capitalStyle: 'none',
}
: {}),
})
}
type="button"
>
<span className="truncate font-medium">{option.label}</span>
</button>
)
})}
</div>
{isBraceSupport ? (
<>
<SliderControl
label="Brace Width"
max={0.8}
min={0.04}
onChange={(value) => handleUpdate({ braceWidth: value, width: value })}
precision={2}
step={0.01}
unit="m"
value={node.braceWidth ?? node.width}
/>
<SliderControl
label="Brace Depth"
max={0.8}
min={0.04}
onChange={(value) => handleUpdate({ braceDepth: value, depth: value })}
precision={2}
step={0.01}
unit="m"
value={node.braceDepth ?? node.depth}
/>
</>
) : (
<>
<select <select
className={SELECT_CLASS} className={SELECT_CLASS}
onChange={(event) => onChange={(event) =>
@@ -261,9 +348,12 @@ export function ColumnPanel() {
value={node.shaftCornerRadius ?? 0.035} value={node.shaftCornerRadius ?? 0.035}
/> />
)} )}
</>
)}
</PanelSection> </PanelSection>
<PanelSection title="Dimensions"> <PanelSection title="Dimensions">
{!isBraceSupport && (
<select <select
className={SELECT_CLASS} className={SELECT_CLASS}
onChange={(event) => { onChange={(event) => {
@@ -279,6 +369,7 @@ export function ColumnPanel() {
</option> </option>
))} ))}
</select> </select>
)}
<SliderControl <SliderControl
label="Height" label="Height"
max={6} max={6}
@@ -289,6 +380,77 @@ export function ColumnPanel() {
unit="m" unit="m"
value={node.height} value={node.height}
/> />
{isBraceSupport ? (
<>
{(supportStyle === 'a-frame' ||
supportStyle === 'x-brace' ||
supportStyle === 'k-brace' ||
supportStyle === 'single-strut' ||
supportStyle === 'tripod' ||
supportStyle === 'trestle' ||
supportStyle === 'portal-frame' ||
supportStyle === 'box-frame') && (
<SliderControl
label="Bottom Spread"
max={4}
min={0.2}
onChange={(value) =>
handleUpdate({
braceBottomSpread: value,
braceTopSpread:
supportStyle === 'a-frame'
? Math.min(node.braceTopSpread ?? 0.12, value)
: (node.braceTopSpread ?? 1),
})
}
precision={2}
step={0.05}
unit="m"
value={node.braceBottomSpread ?? 1.2}
/>
)}
<SliderControl
label={supportStyle === 'y-frame' ? 'Fork Spread' : 'Top Spread'}
max={
supportStyle === 'y-frame' ||
supportStyle === 'v-frame' ||
supportStyle === 'x-brace' ||
supportStyle === 'k-brace' ||
supportStyle === 'single-strut' ||
supportStyle === 'tripod' ||
supportStyle === 'trestle' ||
supportStyle === 'box-frame'
? 4
: Math.max(0.2, node.braceBottomSpread ?? 1.2)
}
min={0}
onChange={(value) => handleUpdate({ braceTopSpread: value })}
precision={2}
step={0.02}
unit="m"
value={
node.braceTopSpread ??
(supportStyle === 'y-frame' ||
supportStyle === 'v-frame' ||
supportStyle === 'x-brace' ||
supportStyle === 'k-brace' ||
supportStyle === 'single-strut' ||
supportStyle === 'tripod' ||
supportStyle === 'trestle' ||
supportStyle === 'portal-frame' ||
supportStyle === 'box-frame'
? 1
: 0.12)
}
/>
<ToggleControl
checked={node.bracePlateEnabled ?? true}
label="Connector Plates"
onChange={(checked) => handleUpdate({ bracePlateEnabled: checked })}
/>
</>
) : (
<>
<SliderControl <SliderControl
label="Width" label="Width"
max={1.6} max={1.6}
@@ -317,13 +479,18 @@ export function ColumnPanel() {
value={node.depth} value={node.depth}
/> />
)} )}
</>
)}
</PanelSection> </PanelSection>
{!isBraceSupport && (
<PanelSection title="Shaft"> <PanelSection title="Shaft">
<select <select
className={SELECT_CLASS} className={SELECT_CLASS}
onChange={(event) => onChange={(event) =>
handleUpdate(shaftProfileUpdates(event.target.value as ColumnNode['shaftProfile'])) handleUpdate(
shaftProfileUpdates(event.target.value as ColumnNode['shaftProfile']),
)
} }
value={shaftProfile} value={shaftProfile}
> >
@@ -380,7 +547,9 @@ export function ColumnPanel() {
label="End Width" label="End Width"
max={1.2} max={1.2}
min={0.3} min={0.3}
onChange={(value) => handleUpdate({ shaftStartScale: value, shaftEndScale: value })} onChange={(value) =>
handleUpdate({ shaftStartScale: value, shaftEndScale: value })
}
precision={2} precision={2}
step={0.02} step={0.02}
value={node.shaftStartScale ?? 0.68} value={node.shaftStartScale ?? 0.68}
@@ -402,7 +571,9 @@ export function ColumnPanel() {
label="End Width" label="End Width"
max={1.2} max={1.2}
min={0.3} min={0.3}
onChange={(value) => handleUpdate({ shaftStartScale: value, shaftEndScale: value })} onChange={(value) =>
handleUpdate({ shaftStartScale: value, shaftEndScale: value })
}
precision={2} precision={2}
step={0.02} step={0.02}
value={node.shaftStartScale ?? 0.84} value={node.shaftStartScale ?? 0.84}
@@ -486,7 +657,9 @@ export function ColumnPanel() {
/> />
)} )}
</PanelSection> </PanelSection>
)}
{!isBraceSupport && (
<PanelSection title="Ends"> <PanelSection title="Ends">
<select <select
className={SELECT_CLASS} className={SELECT_CLASS}
@@ -729,6 +902,7 @@ export function ColumnPanel() {
/> />
)} )}
</PanelSection> </PanelSection>
)}
<PanelSection title="Transform"> <PanelSection title="Transform">
<SliderControl <SliderControl
@@ -127,8 +127,11 @@ export function DoorPanel() {
const handleUpdate = useCallback( const handleUpdate = useCallback(
(updates: Partial<DoorNode>) => { (updates: Partial<DoorNode>) => {
if (!(selectedId && node)) return if (!(selectedId && node)) return
const liveNode = useScene.getState().nodes[selectedId as AnyNodeId]
if (liveNode?.type !== 'door') return
const hasChange = Object.entries(updates).some(([key, value]) => { const hasChange = Object.entries(updates).some(([key, value]) => {
const currentValue = node[key as keyof DoorNode] const currentValue = liveNode[key as keyof DoorNode]
return !isSameDoorValue(currentValue, value) return !isSameDoorValue(currentValue, value)
}) })
if (!hasChange) return if (!hasChange) return
@@ -137,7 +140,9 @@ export function DoorPanel() {
useInteractive.getState().removeDoorOpenState(selectedId as AnyNodeId) useInteractive.getState().removeDoorOpenState(selectedId as AnyNodeId)
} }
updateNode(selectedId as AnyNode['id'], updates) updateNode(selectedId as AnyNode['id'], updates)
useScene.getState().dirtyNodes.add(selectedId as AnyNodeId) const scene = useScene.getState()
scene.dirtyNodes.add(selectedId as AnyNodeId)
if (liveNode.parentId) scene.dirtyNodes.add(liveNode.parentId as AnyNodeId)
}, },
[selectedId, node, updateNode], [selectedId, node, updateNode],
) )
@@ -355,7 +360,9 @@ export function DoorPanel() {
const isRollupGarageDoor = doorType === 'garage-rollup' const isRollupGarageDoor = doorType === 'garage-rollup'
const isTiltupGarageDoor = doorType === 'garage-tiltup' const isTiltupGarageDoor = doorType === 'garage-tiltup'
const typeMode = isOpening ? 'opening' : isGarageDoor ? 'garage' : 'door' const typeMode = isOpening ? 'opening' : isGarageDoor ? 'garage' : 'door'
const supportsHandleSide = isSwingDoor const supportsHingeSide = doorType === 'hinged'
const supportsHandleSide = doorType === 'hinged'
const supportsTopShape = !isGarageDoor
const maxDoorWidth = isGarageDoor ? 6 : 3 const maxDoorWidth = isGarageDoor ? 6 : 3
const setOpeningTopRadius = (index: number, value: number, commit = false) => { const setOpeningTopRadius = (index: number, value: number, commit = false) => {
@@ -402,6 +409,7 @@ export function DoorPanel() {
handleSide: 'right', handleSide: 'right',
trackStyle: 'visible', trackStyle: 'visible',
operationState: Math.max(node.operationState ?? 0, 0.65), operationState: Math.max(node.operationState ?? 0, 0.65),
threshold: false,
contentPadding: [0.03, 0.04], contentPadding: [0.03, 0.04],
segments: foldingDoorSegments, segments: foldingDoorSegments,
} }
@@ -418,6 +426,7 @@ export function DoorPanel() {
trackStyle: 'pocket', trackStyle: 'pocket',
slideDirection: node.slideDirection ?? 'left', slideDirection: node.slideDirection ?? 'left',
operationState: node.operationState ?? 0, operationState: node.operationState ?? 0,
threshold: false,
contentPadding: [0.035, 0.045], contentPadding: [0.035, 0.045],
segments: foldingDoorSegments, segments: foldingDoorSegments,
} }
@@ -434,6 +443,7 @@ export function DoorPanel() {
trackStyle: 'visible', trackStyle: 'visible',
slideDirection: node.slideDirection ?? 'left', slideDirection: node.slideDirection ?? 'left',
operationState: node.operationState ?? 0, operationState: node.operationState ?? 0,
threshold: false,
contentPadding: [0.035, 0.045], contentPadding: [0.035, 0.045],
segments: foldingDoorSegments, segments: foldingDoorSegments,
} }
@@ -450,6 +460,7 @@ export function DoorPanel() {
trackStyle: 'visible', trackStyle: 'visible',
slideDirection: node.slideDirection ?? 'left', slideDirection: node.slideDirection ?? 'left',
operationState: node.operationState ?? 0, operationState: node.operationState ?? 0,
threshold: false,
contentPadding: [0.03, 0.04], contentPadding: [0.03, 0.04],
segments: frenchDoorSegments, segments: frenchDoorSegments,
} }
@@ -463,6 +474,7 @@ export function DoorPanel() {
...dimensionUpdates, ...dimensionUpdates,
handle: false, handle: false,
threshold: false, threshold: false,
openingShape: 'rectangle',
trackStyle: 'overhead', trackStyle: 'overhead',
operationState: 0, operationState: 0,
garagePanelCount: Math.max(3, Math.min(8, node.garagePanelCount ?? 4)), garagePanelCount: Math.max(3, Math.min(8, node.garagePanelCount ?? 4)),
@@ -479,6 +491,7 @@ export function DoorPanel() {
...dimensionUpdates, ...dimensionUpdates,
handle: false, handle: false,
threshold: false, threshold: false,
openingShape: 'rectangle',
trackStyle: 'overhead', trackStyle: 'overhead',
operationState: 0, operationState: 0,
garagePanelCount: 4, garagePanelCount: 4,
@@ -495,6 +508,7 @@ export function DoorPanel() {
...dimensionUpdates, ...dimensionUpdates,
handle: false, handle: false,
threshold: false, threshold: false,
openingShape: 'rectangle',
trackStyle: 'overhead', trackStyle: 'overhead',
operationState: 0, operationState: 0,
garagePanelCount: 4, garagePanelCount: 4,
@@ -746,7 +760,7 @@ export function DoorPanel() {
/> />
</PanelSection> </PanelSection>
{!isOpening && ( {!isOpening && supportsTopShape && (
<PanelSection title="Top Shape"> <PanelSection title="Top Shape">
<div className="flex flex-col gap-2 px-1 pb-1"> <div className="flex flex-col gap-2 px-1 pb-1">
<SegmentedControl <SegmentedControl
@@ -998,6 +1012,7 @@ export function DoorPanel() {
{isSwingDoor && ( {isSwingDoor && (
<PanelSection title="Swing"> <PanelSection title="Swing">
<div className="flex flex-col gap-2 px-1 pb-1"> <div className="flex flex-col gap-2 px-1 pb-1">
{supportsHingeSide && (
<div className="space-y-1"> <div className="space-y-1">
<span className="font-medium text-[10px] text-muted-foreground/80 uppercase tracking-wider"> <span className="font-medium text-[10px] text-muted-foreground/80 uppercase tracking-wider">
Hinges Side Hinges Side
@@ -1011,6 +1026,7 @@ export function DoorPanel() {
value={node.hingesSide} value={node.hingesSide}
/> />
</div> </div>
)}
<div className="space-y-1"> <div className="space-y-1">
<span className="font-medium text-[10px] text-muted-foreground/80 uppercase tracking-wider"> <span className="font-medium text-[10px] text-muted-foreground/80 uppercase tracking-wider">
Direction Direction
@@ -23,6 +23,7 @@ import { MaterialPicker } from '../controls/material-picker'
import { PanelSection } from '../controls/panel-section' import { PanelSection } from '../controls/panel-section'
import { SegmentedControl } from '../controls/segmented-control' import { SegmentedControl } from '../controls/segmented-control'
import { SliderControl } from '../controls/slider-control' import { SliderControl } from '../controls/slider-control'
import { ToggleControl } from '../controls/toggle-control'
import { PanelWrapper } from './panel-wrapper' import { PanelWrapper } from './panel-wrapper'
type FenceStyleValue = 'slat' | 'rail' | 'privacy' type FenceStyleValue = 'slat' | 'rail' | 'privacy'
@@ -110,6 +111,12 @@ export function FencePanel() {
options={FENCE_BASE_STYLE_OPTIONS} options={FENCE_BASE_STYLE_OPTIONS}
value={node.baseStyle} value={node.baseStyle}
/> />
<ToggleControl
checked={node.showInfill ?? true}
className="mt-2"
label="Fence Infill"
onChange={(checked) => handleUpdate({ showInfill: checked })}
/>
</PanelSection> </PanelSection>
<PanelSection title="Dimensions"> <PanelSection title="Dimensions">
@@ -4,60 +4,28 @@ import {
type AnyNode, type AnyNode,
type AnyNodeId, type AnyNodeId,
getClampedWallCurveOffset, getClampedWallCurveOffset,
getEffectiveWallSurfaceMaterial,
getMaxWallCurveOffset, getMaxWallCurveOffset,
getWallCurveLength, getWallCurveLength,
getWallSurfaceMaterialSignature,
type MaterialSchema,
normalizeWallCurveOffset, normalizeWallCurveOffset,
useScene, useScene,
type WallNode, type WallNode,
type WallSurfaceSide,
} from '@pascal-app/core' } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { Move, Spline } from 'lucide-react' import { Move, Spline } from 'lucide-react'
import { useCallback, useMemo } from 'react' import { useCallback } from 'react'
import { sfxEmitter } from '../../../lib/sfx-bus' import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor' import useEditor from '../../../store/use-editor'
import { ActionButton, ActionGroup } from '../controls/action-button' import { ActionButton, ActionGroup } from '../controls/action-button'
import { MaterialPicker } from '../controls/material-picker'
import { PanelSection } from '../controls/panel-section' import { PanelSection } from '../controls/panel-section'
import { SliderControl } from '../controls/slider-control' import { SliderControl } from '../controls/slider-control'
import { PanelWrapper } from './panel-wrapper' import { PanelWrapper } from './panel-wrapper'
function buildWallSurfaceMaterialPatch(
node: WallNode,
targetSide: WallSurfaceSide | null,
material: MaterialSchema | undefined,
materialPreset: string | undefined,
): Partial<WallNode> {
const nextSurfaceMaterial = { material, materialPreset }
const nextInterior =
targetSide === null || targetSide === 'interior'
? nextSurfaceMaterial
: getEffectiveWallSurfaceMaterial(node, 'interior')
const nextExterior =
targetSide === null || targetSide === 'exterior'
? nextSurfaceMaterial
: getEffectiveWallSurfaceMaterial(node, 'exterior')
return {
interiorMaterial: nextInterior.material,
interiorMaterialPreset: nextInterior.materialPreset,
exteriorMaterial: nextExterior.material,
exteriorMaterialPreset: nextExterior.materialPreset,
material: undefined,
materialPreset: undefined,
}
}
export function WallPanel() { export function WallPanel() {
const selectedId = useViewer((s) => s.selection.selectedIds[0]) const selectedId = useViewer((s) => s.selection.selectedIds[0])
const setSelection = useViewer((s) => s.setSelection) const setSelection = useViewer((s) => s.setSelection)
const updateNode = useScene((s) => s.updateNode) const updateNode = useScene((s) => s.updateNode)
const setMovingNode = useEditor((s) => s.setMovingNode) const setMovingNode = useEditor((s) => s.setMovingNode)
const setCurvingWall = useEditor((s) => s.setCurvingWall) const setCurvingWall = useEditor((s) => s.setCurvingWall)
const selectedMaterialTarget = useEditor((s) => s.selectedMaterialTarget)
const node = useScene((s) => const node = useScene((s) =>
selectedId ? (s.nodes[selectedId as AnyNode['id']] as WallNode | undefined) : undefined, selectedId ? (s.nodes[selectedId as AnyNode['id']] as WallNode | undefined) : undefined,
@@ -88,35 +56,6 @@ export function WallPanel() {
[selectedId, updateNode], [selectedId, updateNode],
) )
const effectiveInteriorMaterial = useMemo(
() => (node ? getEffectiveWallSurfaceMaterial(node, 'interior') : {}),
[node],
)
const effectiveExteriorMaterial = useMemo(
() => (node ? getEffectiveWallSurfaceMaterial(node, 'exterior') : {}),
[node],
)
const surfaceMaterialsMatch = useMemo(
() =>
getWallSurfaceMaterialSignature(effectiveInteriorMaterial) ===
getWallSurfaceMaterialSignature(effectiveExteriorMaterial),
[effectiveExteriorMaterial, effectiveInteriorMaterial],
)
const materialTargetSide =
selectedMaterialTarget &&
selectedMaterialTarget.nodeId === node?.id &&
(selectedMaterialTarget.role === 'interior' || selectedMaterialTarget.role === 'exterior')
? selectedMaterialTarget.role
: null
const materialPickerValue =
materialTargetSide === 'interior'
? effectiveInteriorMaterial
: materialTargetSide === 'exterior'
? effectiveExteriorMaterial
: surfaceMaterialsMatch
? effectiveInteriorMaterial
: {}
const handleUpdateLength = useCallback( const handleUpdateLength = useCallback(
(newLength: number) => { (newLength: number) => {
if (!node || newLength <= 0) return if (!node || newLength <= 0) return
@@ -140,24 +79,6 @@ export function WallPanel() {
[node, handleUpdate], [node, handleUpdate],
) )
const handleMaterialPresetChange = useCallback(
(materialPreset: string) => {
if (!(node && materialTargetSide)) return
handleUpdate(
buildWallSurfaceMaterialPatch(node, materialTargetSide, undefined, materialPreset),
)
},
[handleUpdate, materialTargetSide, node],
)
const handleCustomMaterialChange = useCallback(
(material: MaterialSchema) => {
if (!(node && materialTargetSide)) return
handleUpdate(buildWallSurfaceMaterialPatch(node, materialTargetSide, material, undefined))
},
[handleUpdate, materialTargetSide, node],
)
const handleClose = useCallback(() => { const handleClose = useCallback(() => {
setSelection({ selectedIds: [] }) setSelection({ selectedIds: [] })
}, [setSelection]) }, [setSelection])
@@ -239,23 +160,6 @@ export function WallPanel() {
)} )}
</PanelSection> </PanelSection>
<PanelSection title="Material">
{materialTargetSide ? null : (
<div className="mb-3 rounded-lg border border-border/50 bg-[#2C2C2E] px-3 py-2 text-[11px] text-muted-foreground">
Click the wall face you want to edit. Materials now apply to one side at a time.
</div>
)}
<MaterialPicker
disabled={!materialTargetSide}
hideSideControl
nodeType="wall"
onChange={handleCustomMaterialChange}
onSelectMaterialPreset={handleMaterialPresetChange}
selectedMaterialPreset={materialPickerValue.materialPreset}
value={materialPickerValue.material}
/>
</PanelSection>
<PanelSection title="Actions"> <PanelSection title="Actions">
<ActionGroup> <ActionGroup>
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} /> <ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
@@ -16,7 +16,6 @@ import { sfxEmitter } from '../../../lib/sfx-bus'
import { cn } from '../../../lib/utils' import { cn } from '../../../lib/utils'
import useEditor from '../../../store/use-editor' import useEditor from '../../../store/use-editor'
import { ActionButton, ActionGroup } from '../controls/action-button' import { ActionButton, ActionGroup } from '../controls/action-button'
import { MetricControl } from '../controls/metric-control'
import { PanelSection } from '../controls/panel-section' import { PanelSection } from '../controls/panel-section'
import { SegmentedControl } from '../controls/segmented-control' import { SegmentedControl } from '../controls/segmented-control'
import { SliderControl } from '../controls/slider-control' import { SliderControl } from '../controls/slider-control'
@@ -81,14 +80,16 @@ const windowTypeOptions: Array<{ label: string; value: WindowNode['windowType']
{ label: 'Louvered', value: 'louvered' }, { label: 'Louvered', value: 'louvered' },
] ]
const rectangleOnlyWindowTypes = new Set<WindowNode['windowType']>([ const shapedWindowTypes = new Set<WindowNode['windowType']>([
'sliding', 'fixed',
'single-hung', 'casement',
'double-hung', 'awning',
'bay', 'hopper',
'bow', 'louvered',
]) ])
const silllessWindowTypes = new Set<WindowNode['windowType']>(['bay', 'bow'])
export function WindowPanel() { export function WindowPanel() {
const selectedId = useViewer((s) => s.selection.selectedIds[0]) const selectedId = useViewer((s) => s.selection.selectedIds[0])
const setSelection = useViewer((s) => s.setSelection) const setSelection = useViewer((s) => s.setSelection)
@@ -110,14 +111,19 @@ export function WindowPanel() {
const handleUpdate = useCallback( const handleUpdate = useCallback(
(updates: Partial<WindowNode>) => { (updates: Partial<WindowNode>) => {
if (!(selectedId && node)) return if (!(selectedId && node)) return
const liveNode = useScene.getState().nodes[selectedId as AnyNodeId]
if (liveNode?.type !== 'window') return
const hasChange = Object.entries(updates).some(([key, value]) => { const hasChange = Object.entries(updates).some(([key, value]) => {
const currentValue = node[key as keyof WindowNode] const currentValue = liveNode[key as keyof WindowNode]
return !isSameWindowValue(currentValue, value) return !isSameWindowValue(currentValue, value)
}) })
if (!hasChange) return if (!hasChange) return
updateNode(selectedId as AnyNode['id'], updates) updateNode(selectedId as AnyNode['id'], updates)
useScene.getState().dirtyNodes.add(selectedId as AnyNodeId) const scene = useScene.getState()
scene.dirtyNodes.add(selectedId as AnyNodeId)
if (liveNode.parentId) scene.dirtyNodes.add(liveNode.parentId as AnyNodeId)
}, },
[selectedId, node, updateNode], [selectedId, node, updateNode],
) )
@@ -321,6 +327,9 @@ export function WindowPanel() {
node.windowType === 'single-hung' || node.windowType === 'single-hung' ||
node.windowType === 'double-hung' || node.windowType === 'double-hung' ||
node.windowType === 'louvered' node.windowType === 'louvered'
const supportsWindowShape = shapedWindowTypes.has(node.windowType ?? 'fixed')
const supportsGrid = node.windowType === 'fixed'
const supportsSill = !silllessWindowTypes.has(node.windowType)
const setOperationState = (value: number) => { const setOperationState = (value: number) => {
useInteractive.getState().cancelWindowAnimation(node.id) useInteractive.getState().cancelWindowAnimation(node.id)
@@ -472,10 +481,10 @@ export function WindowPanel() {
handleUpdate({ handleUpdate({
windowType: option.value, windowType: option.value,
...(option.value === 'awning' ? { awningDirection } : {}), ...(option.value === 'awning' ? { awningDirection } : {}),
...(rectangleOnlyWindowTypes.has(option.value) ...(!shapedWindowTypes.has(option.value)
? { openingShape: 'rectangle' } ? { openingShape: 'rectangle' }
: {}), : {}),
...(option.value === 'bay' || option.value === 'bow' ? { sill: false } : {}), ...(silllessWindowTypes.has(option.value) ? { sill: false } : {}),
}) })
} }
type="button" type="button"
@@ -605,7 +614,7 @@ export function WindowPanel() {
/> />
</PanelSection> </PanelSection>
{!(isOpening || rectangleOnlyWindowTypes.has(node.windowType)) && ( {!isOpening && supportsWindowShape && (
<PanelSection title="Corner Shape"> <PanelSection title="Corner Shape">
<SegmentedControl <SegmentedControl
onChange={(value) => onChange={(value) =>
@@ -822,6 +831,7 @@ export function WindowPanel() {
/> />
</PanelSection> </PanelSection>
{supportsGrid && (
<PanelSection title="Grid"> <PanelSection title="Grid">
<SliderControl <SliderControl
label="Columns" label="Columns"
@@ -914,7 +924,9 @@ export function WindowPanel() {
</div> </div>
)} )}
</PanelSection> </PanelSection>
)}
{supportsSill && (
<PanelSection title="Sill"> <PanelSection title="Sill">
<ToggleControl <ToggleControl
checked={node.sill} checked={node.sill}
@@ -944,6 +956,7 @@ export function WindowPanel() {
</div> </div>
)} )}
</PanelSection> </PanelSection>
)}
</> </>
)} )}
+15
View File
@@ -3,6 +3,10 @@ import { useViewer } from '@pascal-app/viewer'
import { useEffect } from 'react' import { useEffect } from 'react'
import { closeDoorOpenState, toggleDoorOpenState } from '../lib/door-interaction' import { closeDoorOpenState, toggleDoorOpenState } from '../lib/door-interaction'
import { runRedo, runUndo } from '../lib/history' import { runRedo, runUndo } from '../lib/history'
import {
copySelectedNodesToEditorClipboard,
pasteEditorClipboardToLevel,
} from '../lib/scene-clipboard'
import { sfxEmitter } from '../lib/sfx-bus' import { sfxEmitter } from '../lib/sfx-bus'
import { closeWindowOpenState, toggleWindowOpenState } from '../lib/window-interaction' import { closeWindowOpenState, toggleWindowOpenState } from '../lib/window-interaction'
import useEditor from '../store/use-editor' import useEditor from '../store/use-editor'
@@ -106,6 +110,17 @@ export const useKeyboard = ({
useEditor.getState().setPhase('structure') useEditor.getState().setPhase('structure')
useEditor.getState().setStructureLayer('elements') useEditor.getState().setStructureLayer('elements')
useEditor.getState().setMode('material-paint') useEditor.getState().setMode('material-paint')
} else if (e.key === 'c' && (e.metaKey || e.ctrlKey) && !e.shiftKey) {
if (isVersionPreviewMode) return
e.preventDefault()
copySelectedNodesToEditorClipboard()
} else if (e.key === 'v' && (e.metaKey || e.ctrlKey) && !e.shiftKey) {
if (isVersionPreviewMode) return
e.preventDefault()
const result = pasteEditorClipboardToLevel()
if (result?.pastedIds.length) {
sfxEmitter.emit('sfx:item-place')
}
} else if (e.key === 'z' && (e.metaKey || e.ctrlKey)) { } else if (e.key === 'z' && (e.metaKey || e.ctrlKey)) {
if (isVersionPreviewMode) return if (isVersionPreviewMode) return
e.preventDefault() e.preventDefault()
@@ -1,5 +1,6 @@
import type { import type {
CeilingNode, CeilingNode,
ColumnNode,
DoorNode, DoorNode,
ItemNode, ItemNode,
Point2D, Point2D,
@@ -53,6 +54,11 @@ type CeilingEntry = {
holes: Point2D[][] holes: Point2D[][]
} }
type ColumnEntry = {
column: ColumnNode
polygon: Point2D[]
}
type RoofEntry = { type RoofEntry = {
roof: RoofNode roof: RoofNode
segments: Array<{ segments: Array<{
@@ -71,6 +77,7 @@ type FloorplanSelectionToolContext = {
walls: WallEntry[] walls: WallEntry[]
slabs: SlabEntry[] slabs: SlabEntry[]
ceilings: CeilingEntry[] ceilings: CeilingEntry[]
columns: ColumnEntry[]
roofs: RoofEntry[] roofs: RoofEntry[]
openingHitTolerance: number openingHitTolerance: number
wallHitTolerance: number wallHitTolerance: number
@@ -123,6 +130,13 @@ export function getFloorplanHitNodeId(context: FloorplanSelectionToolContext) {
return stairHit.stair.id return stairHit.stair.id
} }
const columnHit = context.columns.find(({ polygon }) =>
isPointInsidePolygon(context.point, polygon),
)
if (columnHit) {
return columnHit.column.id
}
const wallHit = context.walls.find( const wallHit = context.walls.find(
({ wall, polygon }) => ({ wall, polygon }) =>
isPointInsidePolygon(context.point, polygon) || isPointInsidePolygon(context.point, polygon) ||
@@ -166,6 +180,7 @@ type FloorplanSelectionBoundsContext = {
openings: OpeningPolygonEntry[] openings: OpeningPolygonEntry[]
slabs: SlabEntry[] slabs: SlabEntry[]
ceilings: CeilingEntry[] ceilings: CeilingEntry[]
columns: ColumnEntry[]
stairs: StairEntry[] stairs: StairEntry[]
roofs: RoofEntry[] roofs: RoofEntry[]
} }
@@ -179,6 +194,7 @@ export function getFloorplanSelectionIdsInBounds({
openings, openings,
slabs, slabs,
ceilings, ceilings,
columns,
stairs, stairs,
roofs, roofs,
}: FloorplanSelectionBoundsContext) { }: FloorplanSelectionBoundsContext) {
@@ -204,6 +220,9 @@ export function getFloorplanSelectionIdsInBounds({
const ceilingIds = ceilings const ceilingIds = ceilings
.filter(({ polygon }) => doesPolygonIntersectSelectionBounds(polygon, bounds)) .filter(({ polygon }) => doesPolygonIntersectSelectionBounds(polygon, bounds))
.map(({ ceiling }) => ceiling.id) .map(({ ceiling }) => ceiling.id)
const columnIds = columns
.filter(({ polygon }) => doesPolygonIntersectSelectionBounds(polygon, bounds))
.map(({ column }) => column.id)
const stairIds = stairs const stairIds = stairs
.filter((stair) => .filter((stair) =>
getStairHitPolygons(stair).some((polygon) => getStairHitPolygons(stair).some((polygon) =>
@@ -224,6 +243,7 @@ export function getFloorplanSelectionIdsInBounds({
...openingIds, ...openingIds,
...slabIds, ...slabIds,
...ceilingIds, ...ceilingIds,
...columnIds,
...stairIds, ...stairIds,
...roofIds, ...roofIds,
]), ]),
+267
View File
@@ -0,0 +1,267 @@
import {
AnyNode,
type AnyNodeId,
generateId,
type LevelNode,
type StairNode,
useScene,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
type ClipboardPayload = {
copiedAt: number
nodes: AnyNode[]
rootIds: AnyNodeId[]
}
type PasteResult = {
pastedIds: AnyNodeId[]
skippedIds: AnyNodeId[]
}
const COPYABLE_ROOT_TYPES = new Set<AnyNode['type']>([
'wall',
'fence',
'column',
'item',
'slab',
'ceiling',
'roof',
'stair',
'spawn',
'zone',
])
let clipboardPayload: ClipboardPayload | null = null
const subscribers = new Set<() => void>()
function notifySubscribers() {
for (const subscriber of subscribers) {
subscriber()
}
}
export function subscribeEditorClipboard(subscriber: () => void) {
subscribers.add(subscriber)
return () => {
subscribers.delete(subscriber)
}
}
export function getEditorClipboardSnapshot() {
return clipboardPayload
}
export function hasEditorClipboard() {
return !!clipboardPayload && clipboardPayload.rootIds.length > 0
}
function extractIdPrefix(id: string) {
const underscoreIndex = id.indexOf('_')
return underscoreIndex === -1 ? 'node' : id.slice(0, underscoreIndex)
}
function collectSubtreeIds(
nodes: Record<AnyNodeId, AnyNode>,
rootId: AnyNodeId,
ids: Set<AnyNodeId>,
) {
if (ids.has(rootId)) return
const node = nodes[rootId]
if (!node) return
ids.add(rootId)
if ('children' in node && Array.isArray(node.children)) {
for (const childId of node.children as AnyNodeId[]) {
collectSubtreeIds(nodes, childId, ids)
}
}
}
function hasSelectedAncestor(
nodes: Record<AnyNodeId, AnyNode>,
id: AnyNodeId,
selectedIds: Set<AnyNodeId>,
) {
let parentId = nodes[id]?.parentId as AnyNodeId | null
while (parentId) {
if (selectedIds.has(parentId)) return true
parentId = nodes[parentId]?.parentId as AnyNodeId | null
}
return false
}
function isLevelChildRoot(nodes: Record<AnyNodeId, AnyNode>, node: AnyNode) {
const parentId = node.parentId as AnyNodeId | null
if (!parentId) return true
return nodes[parentId]?.type === 'level'
}
function getPasteTargetLevel(targetLevelId?: AnyNodeId) {
const scene = useScene.getState()
const resolvedLevelId =
targetLevelId ?? (useViewer.getState().selection.levelId as AnyNodeId | null)
if (!resolvedLevelId) return null
const level = scene.nodes[resolvedLevelId]
return level?.type === 'level' ? level : null
}
function getNextLevelId(level: LevelNode, nodes: Record<AnyNodeId, AnyNode>) {
const parentId = level.parentId as AnyNodeId | null
if (!parentId) return null
const building = nodes[parentId]
if (!building || building.type !== 'building') return null
const siblingLevels = building.children
.map((childId) => nodes[childId as AnyNodeId])
.filter((node): node is LevelNode => node?.type === 'level')
return (
siblingLevels
.filter((candidate) => candidate.level > level.level)
.sort((a, b) => a.level - b.level)[0]?.id ?? null
)
}
function remapNodeReferences(
node: AnyNode,
oldId: AnyNodeId,
targetLevel: LevelNode,
idMap: Map<AnyNodeId, AnyNodeId>,
rootIds: Set<AnyNodeId>,
nodes: Record<AnyNodeId, AnyNode>,
) {
const clone = JSON.parse(JSON.stringify(node)) as AnyNode
;(clone as Record<string, unknown>).id = idMap.get(oldId)
if (rootIds.has(oldId)) {
clone.parentId = targetLevel.id
} else if (clone.parentId && typeof clone.parentId === 'string') {
clone.parentId = idMap.get(clone.parentId as AnyNodeId) ?? clone.parentId
}
if ('children' in clone && Array.isArray(clone.children)) {
;(clone as Record<string, unknown>).children = (clone.children as AnyNodeId[])
.map((childId) => idMap.get(childId))
.filter((childId): childId is AnyNodeId => !!childId)
}
if ('wallId' in clone && typeof clone.wallId === 'string') {
const nextWallId = idMap.get(clone.wallId as AnyNodeId)
if (nextWallId) {
;(clone as Record<string, unknown>).wallId = nextWallId
} else {
delete (clone as Record<string, unknown>).wallId
}
}
if (clone.type === 'stair') {
const nextLevelId = getNextLevelId(targetLevel, nodes)
;(clone as StairNode).fromLevelId = targetLevel.id
;(clone as StairNode).toLevelId = nextLevelId
}
const metadata =
clone.metadata && typeof clone.metadata === 'object' && !Array.isArray(clone.metadata)
? { ...(clone.metadata as Record<string, unknown>) }
: {}
delete metadata.isNew
delete metadata.isTransient
;(clone as Record<string, unknown>).metadata = metadata
return AnyNode.parse(clone)
}
export function copySelectedNodesToEditorClipboard(selectedIds?: AnyNodeId[]) {
const scene = useScene.getState()
const ids = selectedIds ?? (useViewer.getState().selection.selectedIds as AnyNodeId[])
const selectedIdSet = new Set(ids)
const rootIds = ids.filter((id) => {
const node = scene.nodes[id]
return (
node &&
COPYABLE_ROOT_TYPES.has(node.type) &&
isLevelChildRoot(scene.nodes, node) &&
!hasSelectedAncestor(scene.nodes, id, selectedIdSet)
)
})
if (rootIds.length === 0) {
return false
}
const subtreeIds = new Set<AnyNodeId>()
for (const rootId of rootIds) {
collectSubtreeIds(scene.nodes, rootId, subtreeIds)
}
clipboardPayload = {
copiedAt: Date.now(),
nodes: [...subtreeIds]
.map((id) => scene.nodes[id])
.filter((node): node is AnyNode => !!node)
.map((node) => JSON.parse(JSON.stringify(node)) as AnyNode),
rootIds,
}
notifySubscribers()
return true
}
export function pasteEditorClipboardToLevel(targetLevelId?: AnyNodeId): PasteResult | null {
const payload = clipboardPayload
const targetLevel = getPasteTargetLevel(targetLevelId)
if (!payload || !targetLevel) return null
const scene = useScene.getState()
const idMap = new Map<AnyNodeId, AnyNodeId>()
for (const node of payload.nodes) {
idMap.set(node.id as AnyNodeId, generateId(extractIdPrefix(node.id)) as AnyNodeId)
}
const rootIdSet = new Set(payload.rootIds)
const pastedNodes: AnyNode[] = []
const skippedIds: AnyNodeId[] = []
for (const node of payload.nodes) {
try {
pastedNodes.push(
remapNodeReferences(node, node.id as AnyNodeId, targetLevel, idMap, rootIdSet, scene.nodes),
)
} catch (error) {
console.error('Failed to paste copied node', node.id, error)
skippedIds.push(node.id as AnyNodeId)
}
}
if (pastedNodes.length === 0) {
return { pastedIds: [], skippedIds }
}
scene.createNodes(
pastedNodes.map((node) => ({
node,
parentId: (node.parentId as AnyNodeId | null) ?? undefined,
})),
)
const pastedNodeIds = new Set(pastedNodes.map((node) => node.id as AnyNodeId))
const pastedRootIds = payload.rootIds
.map((rootId) => idMap.get(rootId))
.filter((id): id is AnyNodeId => !!id && pastedNodeIds.has(id))
useViewer.getState().setSelection({
levelId: targetLevel.id,
selectedIds: pastedRootIds,
})
return {
pastedIds: pastedRootIds,
skippedIds,
}
}
-1
View File
@@ -29,7 +29,6 @@
"three": "^0.184" "three": "^0.184"
}, },
"dependencies": { "dependencies": {
"polygon-clipping": "^0.15.7",
"three-bvh-csg": "^0.0.18", "three-bvh-csg": "^0.0.18",
"three-mesh-bvh": "^0.9.8", "three-mesh-bvh": "^0.9.8",
"zustand": "^5" "zustand": "^5"
@@ -4,12 +4,19 @@ import {
resolveMaterial, resolveMaterial,
useRegistry, useRegistry,
} from '@pascal-app/core' } from '@pascal-app/core'
import { useMemo, useRef } from 'react' import { useEffect, useMemo, useRef } from 'react'
import { BufferGeometry, Float32BufferAttribute } from 'three'
import { float, mix, positionWorld, smoothstep } from 'three/tsl' import { float, mix, positionWorld, smoothstep } from 'three/tsl'
import { BackSide, FrontSide, type Mesh, MeshBasicNodeMaterial } from 'three/webgpu' import { BackSide, FrontSide, type Mesh, MeshBasicNodeMaterial } from 'three/webgpu'
import { useNodeEvents } from '../../../hooks/use-node-events' import { useNodeEvents } from '../../../hooks/use-node-events'
import { NodeRenderer } from '../node-renderer' import { NodeRenderer } from '../node-renderer'
function createEmptyGeometry() {
const geometry = new BufferGeometry()
geometry.setAttribute('position', new Float32BufferAttribute([], 3))
return geometry
}
const gridScale = 5 const gridScale = 5
const gridX = positionWorld.x.mul(gridScale).fract() const gridX = positionWorld.x.mul(gridScale).fract()
const gridY = positionWorld.z.mul(gridScale).fract() const gridY = positionWorld.z.mul(gridScale).fract()
@@ -51,10 +58,20 @@ function getCeilingMaterials(color = '#999999') {
export const CeilingRenderer = ({ node }: { node: CeilingNode }) => { export const CeilingRenderer = ({ node }: { node: CeilingNode }) => {
const ref = useRef<Mesh>(null!) const ref = useRef<Mesh>(null!)
const placeholderGeometry = useMemo(createEmptyGeometry, [])
const gridPlaceholderGeometry = useMemo(createEmptyGeometry, [])
useRegistry(node.id, 'ceiling', ref) useRegistry(node.id, 'ceiling', ref)
const handlers = useNodeEvents(node, 'ceiling') const handlers = useNodeEvents(node, 'ceiling')
useEffect(
() => () => {
placeholderGeometry.dispose()
gridPlaceholderGeometry.dispose()
},
[gridPlaceholderGeometry, placeholderGeometry],
)
const materials = useMemo(() => { const materials = useMemo(() => {
const preset = getMaterialPresetByRef(node.materialPreset) const preset = getMaterialPresetByRef(node.materialPreset)
const props = preset?.mapProperties ?? resolveMaterial(node.material) const props = preset?.mapProperties ?? resolveMaterial(node.material)
@@ -69,17 +86,15 @@ export const CeilingRenderer = ({ node }: { node: CeilingNode }) => {
]) ])
return ( return (
<mesh material={materials.bottomMaterial} ref={ref}> <mesh geometry={placeholderGeometry} material={materials.bottomMaterial} ref={ref}>
<boxGeometry args={[0, 0, 0]} />
<mesh <mesh
geometry={gridPlaceholderGeometry}
material={materials.topMaterial} material={materials.topMaterial}
name="ceiling-grid" name="ceiling-grid"
{...handlers} {...handlers}
scale={0} scale={0}
visible={false} visible={false}
> />
<boxGeometry args={[0, 0, 0]} />
</mesh>
{node.children.map((childId) => ( {node.children.map((childId) => (
<NodeRenderer key={childId} nodeId={childId} /> <NodeRenderer key={childId} nodeId={childId} />
))} ))}
@@ -1,6 +1,6 @@
import { type ColumnNode, useLiveTransforms, useRegistry } from '@pascal-app/core' import { type ColumnNode, useLiveTransforms, useRegistry } from '@pascal-app/core'
import { createContext, useContext, useMemo, useRef } from 'react' import { createContext, useContext, useMemo, useRef } from 'react'
import type { Group, Material } from 'three' import { BufferGeometry, Float32BufferAttribute, type Group, type Material } from 'three'
import { useNodeEvents } from '../../../hooks/use-node-events' import { useNodeEvents } from '../../../hooks/use-node-events'
import { baseMaterial, createMaterial, createMaterialFromPresetRef } from '../../../lib/materials' import { baseMaterial, createMaterial, createMaterialFromPresetRef } from '../../../lib/materials'
import { import {
@@ -84,6 +84,10 @@ function getShaftScaleAt(node: ColumnNode, t: number) {
type VectorTuple = [number, number, number] type VectorTuple = [number, number, number]
function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value))
}
function MappedBox({ function MappedBox({
depth, depth,
height, height,
@@ -117,6 +121,605 @@ function MappedBox({
) )
} }
function FlatEndedBeam({
depth,
end,
start,
width,
}: {
depth: number
end: VectorTuple
start: VectorTuple
width: number
}) {
const dx = end[0] - start[0]
const dy = end[1] - start[1]
const dz = end[2] - start[2]
const length = Math.hypot(dx, dy, dz)
const geometry = useMemo(() => {
if (length <= 0.001 || width <= 0 || depth <= 0) return null
const halfWidth = width / 2
const halfDepth = depth / 2
const bottomY = start[1]
const topY = end[1]
const bottomCenterX = start[0]
const topCenterX = end[0]
const bottomCenterZ = start[2]
const topCenterZ = end[2]
const vertices: VectorTuple[] = [
[bottomCenterX - halfWidth, bottomY, bottomCenterZ - halfDepth],
[bottomCenterX + halfWidth, bottomY, bottomCenterZ - halfDepth],
[bottomCenterX + halfWidth, bottomY, bottomCenterZ + halfDepth],
[bottomCenterX - halfWidth, bottomY, bottomCenterZ + halfDepth],
[topCenterX - halfWidth, topY, topCenterZ - halfDepth],
[topCenterX + halfWidth, topY, topCenterZ - halfDepth],
[topCenterX + halfWidth, topY, topCenterZ + halfDepth],
[topCenterX - halfWidth, topY, topCenterZ + halfDepth],
]
const faceQuads: [number, number, number, number][] = [
[0, 1, 2, 3],
[4, 7, 6, 5],
[0, 4, 5, 1],
[1, 5, 6, 2],
[2, 6, 7, 3],
[3, 7, 4, 0],
]
const positions: number[] = []
const uvs: number[] = []
const pushVertex = (vertexIndex: number, uv: [number, number]) => {
const vertex = vertices[vertexIndex]
if (!vertex) return false
positions.push(...vertex)
uvs.push(...uv)
return true
}
const pushTriangle = (
a: number,
b: number,
c: number,
uvA: [number, number],
uvB: [number, number],
uvC: [number, number],
) => {
const va = vertices[a]
const vb = vertices[b]
const vc = vertices[c]
if (!va || !vb || !vc) return
pushVertex(a, uvA)
pushVertex(b, uvB)
pushVertex(c, uvC)
}
for (const [a, b, c, d] of faceQuads) {
const va = vertices[a]
const vb = vertices[b]
const vc = vertices[c]
const vd = vertices[d]
if (!va || !vb || !vc || !vd) continue
const edgeU = Math.hypot(vb[0] - va[0], vb[1] - va[1], vb[2] - va[2])
const edgeV = Math.hypot(vd[0] - va[0], vd[1] - va[1], vd[2] - va[2])
const uvA: [number, number] = [0, 0]
const uvB: [number, number] = [edgeU, 0]
const uvC: [number, number] = [edgeU, edgeV]
const uvD: [number, number] = [0, edgeV]
pushTriangle(a, b, c, uvA, uvB, uvC)
pushTriangle(a, c, d, uvA, uvC, uvD)
pushTriangle(a, c, b, uvA, uvC, uvB)
pushTriangle(a, d, c, uvA, uvD, uvC)
}
const geometry = new BufferGeometry()
geometry.setAttribute('position', new Float32BufferAttribute(positions, 3))
geometry.setAttribute('uv', new Float32BufferAttribute(uvs, 2))
geometry.setAttribute('uv2', new Float32BufferAttribute(uvs.slice(), 2))
geometry.computeVertexNormals()
return geometry
}, [depth, length, start, end, width])
if (!geometry) return null
return (
<mesh dispose={null}>
<primitive attach="geometry" dispose={null} object={geometry} />
<ColumnMaterial />
</mesh>
)
}
function AFrameSupport({ node }: { node: ColumnNode }) {
const height = Math.max(0.2, node.height)
const braceWidth = clamp(node.braceWidth ?? node.width, 0.04, 1.6)
const braceDepth = clamp(node.braceDepth ?? node.depth, 0.04, 1.6)
const bottomSpread = Math.max(0.2, node.braceBottomSpread ?? Math.max(node.width * 3, 1.2))
const topSpread = clamp(node.braceTopSpread ?? 0.12, 0, bottomSpread)
const bottomY = 0
const topY = height
const leftBottom: VectorTuple = [-bottomSpread / 2, bottomY, 0]
const rightBottom: VectorTuple = [bottomSpread / 2, bottomY, 0]
const leftTop: VectorTuple = [-topSpread / 2, topY, 0]
const rightTop: VectorTuple = [topSpread / 2, topY, 0]
const plateHeight = Math.max(0.035, Math.min(0.08, braceWidth * 0.45))
const footPlateWidth = braceWidth * 1.9
const footPlateDepth = braceDepth * 1.75
const topPlateWidth = Math.max(topSpread + braceWidth * 1.9, braceWidth * 2.2)
const topPlateDepth = braceDepth * 1.75
return (
<group>
<FlatEndedBeam depth={braceDepth} end={leftTop} start={leftBottom} width={braceWidth} />
<FlatEndedBeam depth={braceDepth} end={rightTop} start={rightBottom} width={braceWidth} />
{(node.bracePlateEnabled ?? true) && (
<>
<MappedBox
depth={footPlateDepth}
height={plateHeight}
position={[leftBottom[0], plateHeight / 2, leftBottom[2]]}
width={footPlateWidth}
/>
<MappedBox
depth={footPlateDepth}
height={plateHeight}
position={[rightBottom[0], plateHeight / 2, rightBottom[2]]}
width={footPlateWidth}
/>
<MappedBox
depth={topPlateDepth}
height={plateHeight}
position={[0, height - plateHeight / 2, 0]}
width={topPlateWidth}
/>
</>
)}
</group>
)
}
function YFrameSupport({ node }: { node: ColumnNode }) {
const height = Math.max(0.2, node.height)
const braceWidth = clamp(node.braceWidth ?? node.width, 0.04, 1.6)
const braceDepth = clamp(node.braceDepth ?? node.depth, 0.04, 1.6)
const topSpread = Math.max(0.2, node.braceTopSpread ?? 0.9)
const splitY = height * 0.56
const foot: VectorTuple = [0, 0, 0]
const split: VectorTuple = [0, splitY, 0]
const leftTop: VectorTuple = [-topSpread / 2, height, 0]
const rightTop: VectorTuple = [topSpread / 2, height, 0]
const plateHeight = Math.max(0.035, Math.min(0.08, braceWidth * 0.45))
const footPlateWidth = braceWidth * 1.9
const footPlateDepth = braceDepth * 1.75
const topPlateWidth = topSpread + braceWidth * 1.9
const topPlateDepth = braceDepth * 1.75
return (
<group>
<FlatEndedBeam depth={braceDepth} end={split} start={foot} width={braceWidth} />
<FlatEndedBeam depth={braceDepth} end={leftTop} start={split} width={braceWidth} />
<FlatEndedBeam depth={braceDepth} end={rightTop} start={split} width={braceWidth} />
{(node.bracePlateEnabled ?? true) && (
<>
<MappedBox
depth={footPlateDepth}
height={plateHeight}
position={[foot[0], plateHeight / 2, foot[2]]}
width={footPlateWidth}
/>
<MappedBox
depth={topPlateDepth}
height={plateHeight}
position={[0, height - plateHeight / 2, 0]}
width={topPlateWidth}
/>
</>
)}
</group>
)
}
function VFrameSupport({ node }: { node: ColumnNode }) {
const height = Math.max(0.2, node.height)
const braceWidth = clamp(node.braceWidth ?? node.width, 0.04, 1.6)
const braceDepth = clamp(node.braceDepth ?? node.depth, 0.04, 1.6)
const topSpread = Math.max(0.2, node.braceTopSpread ?? 1)
const foot: VectorTuple = [0, 0, 0]
const leftTop: VectorTuple = [-topSpread / 2, height, 0]
const rightTop: VectorTuple = [topSpread / 2, height, 0]
const plateHeight = Math.max(0.035, Math.min(0.08, braceWidth * 0.45))
const footPlateWidth = braceWidth * 1.9
const footPlateDepth = braceDepth * 1.75
const topPlateWidth = topSpread + braceWidth * 1.9
const topPlateDepth = braceDepth * 1.75
return (
<group>
<FlatEndedBeam depth={braceDepth} end={leftTop} start={foot} width={braceWidth} />
<FlatEndedBeam depth={braceDepth} end={rightTop} start={foot} width={braceWidth} />
{(node.bracePlateEnabled ?? true) && (
<>
<MappedBox
depth={footPlateDepth}
height={plateHeight}
position={[foot[0], plateHeight / 2, foot[2]]}
width={footPlateWidth}
/>
<MappedBox
depth={topPlateDepth}
height={plateHeight}
position={[0, height - plateHeight / 2, 0]}
width={topPlateWidth}
/>
</>
)}
</group>
)
}
function XBraceSupport({ node }: { node: ColumnNode }) {
const height = Math.max(0.2, node.height)
const braceWidth = clamp(node.braceWidth ?? node.width, 0.04, 1.6)
const braceDepth = clamp(node.braceDepth ?? node.depth, 0.04, 1.6)
const bottomSpread = Math.max(0.2, node.braceBottomSpread ?? 1)
const topSpread = Math.max(0.2, node.braceTopSpread ?? 1)
const leftBottom: VectorTuple = [-bottomSpread / 2, 0, 0]
const rightBottom: VectorTuple = [bottomSpread / 2, 0, 0]
const leftTop: VectorTuple = [-topSpread / 2, height, 0]
const rightTop: VectorTuple = [topSpread / 2, height, 0]
const plateHeight = Math.max(0.035, Math.min(0.08, braceWidth * 0.45))
const footPlateWidth = braceWidth * 1.9
const footPlateDepth = braceDepth * 1.75
const topPlateWidth = braceWidth * 1.9
const topPlateDepth = braceDepth * 1.75
return (
<group>
<FlatEndedBeam depth={braceDepth} end={rightTop} start={leftBottom} width={braceWidth} />
<FlatEndedBeam depth={braceDepth} end={leftTop} start={rightBottom} width={braceWidth} />
{(node.bracePlateEnabled ?? true) && (
<>
<MappedBox
depth={footPlateDepth}
height={plateHeight}
position={[leftBottom[0], plateHeight / 2, leftBottom[2]]}
width={footPlateWidth}
/>
<MappedBox
depth={footPlateDepth}
height={plateHeight}
position={[rightBottom[0], plateHeight / 2, rightBottom[2]]}
width={footPlateWidth}
/>
<MappedBox
depth={topPlateDepth}
height={plateHeight}
position={[leftTop[0], height - plateHeight / 2, leftTop[2]]}
width={topPlateWidth}
/>
<MappedBox
depth={topPlateDepth}
height={plateHeight}
position={[rightTop[0], height - plateHeight / 2, rightTop[2]]}
width={topPlateWidth}
/>
</>
)}
</group>
)
}
function KBraceSupport({ node }: { node: ColumnNode }) {
const height = Math.max(0.2, node.height)
const braceWidth = clamp(node.braceWidth ?? node.width, 0.04, 1.6)
const braceDepth = clamp(node.braceDepth ?? node.depth, 0.04, 1.6)
const spread = Math.max(0.2, Math.max(node.braceBottomSpread ?? 1, node.braceTopSpread ?? 1))
const leftBottom: VectorTuple = [-spread / 2, 0, 0]
const leftTop: VectorTuple = [-spread / 2, height, 0]
const centerBottom: VectorTuple = [0, 0, 0]
const centerMiddle: VectorTuple = [0, height / 2, 0]
const centerTop: VectorTuple = [0, height, 0]
const plateHeight = Math.max(0.035, Math.min(0.08, braceWidth * 0.45))
const plateWidth = braceWidth * 1.9
const plateDepth = braceDepth * 1.75
return (
<group>
<FlatEndedBeam depth={braceDepth} end={centerTop} start={centerBottom} width={braceWidth} />
<FlatEndedBeam depth={braceDepth} end={centerMiddle} start={leftBottom} width={braceWidth} />
<FlatEndedBeam depth={braceDepth} end={centerMiddle} start={leftTop} width={braceWidth} />
{(node.bracePlateEnabled ?? true) && (
<>
<MappedBox
depth={plateDepth}
height={plateHeight}
position={[leftBottom[0], plateHeight / 2, leftBottom[2]]}
width={plateWidth}
/>
<MappedBox
depth={plateDepth}
height={plateHeight}
position={[centerBottom[0], plateHeight / 2, centerBottom[2]]}
width={plateWidth}
/>
<MappedBox
depth={plateDepth}
height={plateHeight}
position={[leftTop[0], height - plateHeight / 2, leftTop[2]]}
width={plateWidth}
/>
<MappedBox
depth={plateDepth}
height={plateHeight}
position={[centerTop[0], height - plateHeight / 2, centerTop[2]]}
width={plateWidth}
/>
</>
)}
</group>
)
}
function SingleStrutSupport({ node }: { node: ColumnNode }) {
const height = Math.max(0.2, node.height)
const braceWidth = clamp(node.braceWidth ?? node.width, 0.04, 1.6)
const braceDepth = clamp(node.braceDepth ?? node.depth, 0.04, 1.6)
const spread = Math.max(0.2, Math.max(node.braceBottomSpread ?? 1, node.braceTopSpread ?? 1))
const bottom: VectorTuple = [-spread / 2, 0, 0]
const top: VectorTuple = [spread / 2, height, 0]
const plateHeight = Math.max(0.035, Math.min(0.08, braceWidth * 0.45))
const plateWidth = braceWidth * 1.9
const plateDepth = braceDepth * 1.75
return (
<group>
<FlatEndedBeam depth={braceDepth} end={top} start={bottom} width={braceWidth} />
{(node.bracePlateEnabled ?? true) && (
<>
<MappedBox
depth={plateDepth}
height={plateHeight}
position={[bottom[0], plateHeight / 2, bottom[2]]}
width={plateWidth}
/>
<MappedBox
depth={plateDepth}
height={plateHeight}
position={[top[0], height - plateHeight / 2, top[2]]}
width={plateWidth}
/>
</>
)}
</group>
)
}
function TripodSupport({ node }: { node: ColumnNode }) {
const height = Math.max(0.2, node.height)
const braceWidth = clamp(node.braceWidth ?? node.width, 0.04, 1.6)
const braceDepth = clamp(node.braceDepth ?? node.depth, 0.04, 1.6)
const width = Math.max(0.2, node.braceBottomSpread ?? 1.1)
const depth = Math.max(0.2, node.braceTopSpread ?? 1.1)
const top: VectorTuple = [0, height, 0]
const feet: VectorTuple[] = [
[0, 0, -depth / 2],
[-width / 2, 0, depth / 2],
[width / 2, 0, depth / 2],
]
const plateHeight = Math.max(0.035, Math.min(0.08, braceWidth * 0.45))
const plateWidth = braceWidth * 1.9
const plateDepth = braceDepth * 1.75
return (
<group>
{feet.map((foot, index) => (
<FlatEndedBeam
depth={braceDepth}
end={top}
key={`leg-${index}`}
start={foot}
width={braceWidth}
/>
))}
{(node.bracePlateEnabled ?? true) && (
<>
{feet.map((foot, index) => (
<MappedBox
depth={plateDepth}
height={plateHeight}
key={`foot-${index}`}
position={[foot[0], plateHeight / 2, foot[2]]}
width={plateWidth}
/>
))}
<MappedBox
depth={plateDepth}
height={plateHeight}
position={[0, height - plateHeight / 2, 0]}
width={plateWidth}
/>
</>
)}
</group>
)
}
function TrestleSupport({ node }: { node: ColumnNode }) {
const height = Math.max(0.2, node.height)
const braceWidth = clamp(node.braceWidth ?? node.width, 0.04, 1.6)
const braceDepth = clamp(node.braceDepth ?? node.depth, 0.04, 1.6)
const width = Math.max(0.2, node.braceBottomSpread ?? 1.2)
const depth = Math.max(0.2, node.braceTopSpread ?? 1)
const zPositions = [-depth / 2, depth / 2]
const topPoints: VectorTuple[] = zPositions.map((z) => [0, height, z])
const footPoints: VectorTuple[] = zPositions.flatMap((z) => [
[-width / 2, 0, z] as VectorTuple,
[width / 2, 0, z] as VectorTuple,
])
const plateHeight = Math.max(0.035, Math.min(0.08, braceWidth * 0.45))
const plateWidth = braceWidth * 1.9
const plateDepth = braceDepth * 1.75
return (
<group>
{zPositions.map((z, index) => {
const leftBottom: VectorTuple = [-width / 2, 0, z]
const rightBottom: VectorTuple = [width / 2, 0, z]
const top: VectorTuple = topPoints[index] ?? [0, height, z]
return (
<group key={`frame-${z}`}>
<FlatEndedBeam depth={braceDepth} end={top} start={leftBottom} width={braceWidth} />
<FlatEndedBeam depth={braceDepth} end={top} start={rightBottom} width={braceWidth} />
</group>
)
})}
<FlatEndedBeam
depth={braceDepth}
end={topPoints[1] ?? [0, height, depth / 2]}
start={topPoints[0] ?? [0, height, -depth / 2]}
width={braceWidth}
/>
{(node.bracePlateEnabled ?? true) && (
<>
{footPoints.map((foot, index) => (
<MappedBox
depth={plateDepth}
height={plateHeight}
key={`foot-${index}`}
position={[foot[0], plateHeight / 2, foot[2]]}
width={plateWidth}
/>
))}
{topPoints.map((top, index) => (
<MappedBox
depth={plateDepth}
height={plateHeight}
key={`top-${index}`}
position={[top[0], height - plateHeight / 2, top[2]]}
width={plateWidth}
/>
))}
</>
)}
</group>
)
}
function PortalFrameSupport({ node }: { node: ColumnNode }) {
const height = Math.max(0.2, node.height)
const braceWidth = clamp(node.braceWidth ?? node.width, 0.04, 1.6)
const braceDepth = clamp(node.braceDepth ?? node.depth, 0.04, 1.6)
const width = Math.max(0.2, node.braceBottomSpread ?? 1.4)
const leftBottom: VectorTuple = [-width / 2, 0, 0]
const rightBottom: VectorTuple = [width / 2, 0, 0]
const leftTop: VectorTuple = [-width / 2, height, 0]
const rightTop: VectorTuple = [width / 2, height, 0]
const plateHeight = Math.max(0.035, Math.min(0.08, braceWidth * 0.45))
const plateWidth = braceWidth * 1.9
const plateDepth = braceDepth * 1.75
return (
<group>
<FlatEndedBeam depth={braceDepth} end={leftTop} start={leftBottom} width={braceWidth} />
<FlatEndedBeam depth={braceDepth} end={rightTop} start={rightBottom} width={braceWidth} />
<FlatEndedBeam depth={braceDepth} end={rightTop} start={leftTop} width={braceWidth} />
{(node.bracePlateEnabled ?? true) && (
<>
{[leftBottom, rightBottom].map((foot, index) => (
<MappedBox
depth={plateDepth}
height={plateHeight}
key={`foot-${index}`}
position={[foot[0], plateHeight / 2, foot[2]]}
width={plateWidth}
/>
))}
{[leftTop, rightTop].map((top, index) => (
<MappedBox
depth={plateDepth}
height={plateHeight}
key={`top-${index}`}
position={[top[0], height - plateHeight / 2, top[2]]}
width={plateWidth}
/>
))}
</>
)}
</group>
)
}
function BoxFrameSupport({ node }: { node: ColumnNode }) {
const height = Math.max(0.2, node.height)
const braceWidth = clamp(node.braceWidth ?? node.width, 0.04, 1.6)
const braceDepth = clamp(node.braceDepth ?? node.depth, 0.04, 1.6)
const width = Math.max(0.2, node.braceBottomSpread ?? 1.4)
const depth = Math.max(0.2, node.braceTopSpread ?? 1)
const corners: VectorTuple[] = [
[-width / 2, 0, -depth / 2],
[width / 2, 0, -depth / 2],
[width / 2, 0, depth / 2],
[-width / 2, 0, depth / 2],
]
const topCorners = corners.map(([x, _y, z]) => [x, height, z] as VectorTuple)
const plateHeight = Math.max(0.035, Math.min(0.08, braceWidth * 0.45))
const plateWidth = braceWidth * 1.9
const plateDepth = braceDepth * 1.75
return (
<group>
{corners.map((corner, index) => (
<FlatEndedBeam
depth={braceDepth}
end={topCorners[index] ?? [corner[0], height, corner[2]]}
key={`post-${index}`}
start={corner}
width={braceWidth}
/>
))}
{topCorners.map((corner, index) => (
<FlatEndedBeam
depth={braceDepth}
end={topCorners[(index + 1) % topCorners.length] ?? corner}
key={`top-rail-${index}`}
start={corner}
width={braceWidth}
/>
))}
{corners.map((corner, index) => (
<FlatEndedBeam
depth={braceDepth}
end={corners[(index + 1) % corners.length] ?? corner}
key={`bottom-rail-${index}`}
start={corner}
width={braceWidth}
/>
))}
{(node.bracePlateEnabled ?? true) && (
<>
{corners.map((corner, index) => (
<MappedBox
depth={plateDepth}
height={plateHeight}
key={`foot-${index}`}
position={[corner[0], plateHeight / 2, corner[2]]}
width={plateWidth}
/>
))}
{topCorners.map((corner, index) => (
<MappedBox
depth={plateDepth}
height={plateHeight}
key={`top-${index}`}
position={[corner[0], height - plateHeight / 2, corner[2]]}
width={plateWidth}
/>
))}
</>
)}
</group>
)
}
function MappedCylinder({ function MappedCylinder({
height, height,
position, position,
@@ -244,7 +847,14 @@ function MappedTorus({
}) { }) {
const geometry = useMemo(() => { const geometry = useMemo(() => {
if (ringRadius <= 0 || tubeRadius <= 0) return null if (ringRadius <= 0 || tubeRadius <= 0) return null
return createColumnTorusGeometry({ arc, ringRadius, scaleX, scaleY, scaleZ, tubeRadius }) return createColumnTorusGeometry({
arc,
ringRadius,
scaleX,
scaleY,
scaleZ,
tubeRadius,
})
}, [arc, ringRadius, scaleX, scaleY, scaleZ, tubeRadius]) }, [arc, ringRadius, scaleX, scaleY, scaleZ, tubeRadius])
if (!geometry) return null if (!geometry) return null
@@ -1449,7 +2059,11 @@ export const ColumnRenderer = ({ node }: { node: ColumnNode }) => {
const handlers = useNodeEvents(node, 'column') const handlers = useNodeEvents(node, 'column')
const liveTransform = useLiveTransforms((state) => state.get(node.id)) const liveTransform = useLiveTransforms((state) => state.get(node.id))
const material = useMemo( const material = useMemo(
() => createColumnMaterial({ material: node.material, materialPreset: node.materialPreset }), () =>
createColumnMaterial({
material: node.material,
materialPreset: node.materialPreset,
}),
[ [
node.material, node.material,
node.material?.preset, node.material?.preset,
@@ -1479,16 +2093,46 @@ export const ColumnRenderer = ({ node }: { node: ColumnNode }) => {
visible={node.visible} visible={node.visible}
{...handlers} {...handlers}
> >
{node.supportStyle === 'a-frame' ? (
<AFrameSupport node={node} />
) : node.supportStyle === 'y-frame' ? (
<YFrameSupport node={node} />
) : node.supportStyle === 'v-frame' ? (
<VFrameSupport node={node} />
) : node.supportStyle === 'x-brace' ? (
<XBraceSupport node={node} />
) : node.supportStyle === 'k-brace' ? (
<KBraceSupport node={node} />
) : node.supportStyle === 'single-strut' ? (
<SingleStrutSupport node={node} />
) : node.supportStyle === 'tripod' ? (
<TripodSupport node={node} />
) : node.supportStyle === 'trestle' ? (
<TrestleSupport node={node} />
) : node.supportStyle === 'portal-frame' ? (
<PortalFrameSupport node={node} />
) : node.supportStyle === 'box-frame' ? (
<BoxFrameSupport node={node} />
) : (
<>
<Base height={shaftLayout.baseHeight} node={node} /> <Base height={shaftLayout.baseHeight} node={node} />
<BaseCarvings height={shaftLayout.baseHeight} node={node} /> <BaseCarvings height={shaftLayout.baseHeight} node={node} />
<Shaft height={shaftLayout.shaftHeight} node={node} y={shaftLayout.shaftY} /> <Shaft height={shaftLayout.shaftHeight} node={node} y={shaftLayout.shaftY} />
<Rings node={node} shaftHeight={shaftLayout.shaftHeight} shaftY={shaftLayout.shaftY} /> <Rings
node={node}
shaftHeight={shaftLayout.shaftHeight}
shaftY={shaftLayout.shaftY}
/>
<LatheBands <LatheBands
node={node} node={node}
shaftHeight={shaftLayout.shaftHeight} shaftHeight={shaftLayout.shaftHeight}
shaftY={shaftLayout.shaftY} shaftY={shaftLayout.shaftY}
/> />
<Flutes node={node} shaftHeight={shaftLayout.shaftHeight} shaftY={shaftLayout.shaftY} /> <Flutes
node={node}
shaftHeight={shaftLayout.shaftHeight}
shaftY={shaftLayout.shaftY}
/>
<LowerCarvedBand <LowerCarvedBand
node={node} node={node}
shaftHeight={shaftLayout.shaftHeight} shaftHeight={shaftLayout.shaftHeight}
@@ -1514,6 +2158,8 @@ export const ColumnRenderer = ({ node }: { node: ColumnNode }) => {
capitalY={shaftLayout.baseHeight + shaftLayout.shaftHeight} capitalY={shaftLayout.baseHeight + shaftLayout.shaftHeight}
node={node} node={node}
/> />
</>
)}
</group> </group>
</ColumnEdgeSoftnessContext.Provider> </ColumnEdgeSoftnessContext.Provider>
</ColumnMaterialContext.Provider> </ColumnMaterialContext.Provider>
@@ -156,9 +156,12 @@ const ModelRenderer = ({ node }: { node: ItemNode }) => {
const lightEffects = const lightEffects =
interactive?.effects.filter((e): e is LightEffect => e.kind === 'light') ?? [] interactive?.effects.filter((e): e is LightEffect => e.kind === 'light') ?? []
// useGLTF caches scenes, and Clone shares child geometry/material references.
// Undo can unmount one item while another clone of the same asset still needs them.
return ( return (
<> <>
<Clone <Clone
dispose={null}
object={scene} object={scene}
position={node.asset.offset} position={node.asset.offset}
ref={ref} ref={ref}
@@ -1,5 +1,4 @@
import { type SiteNode, type SlabNode, useRegistry, useScene } from '@pascal-app/core' import { type SiteNode, type SlabNode, useRegistry, useScene } from '@pascal-app/core'
import polygonClipping from 'polygon-clipping'
import { useMemo, useRef } from 'react' import { useMemo, useRef } from 'react'
import { BufferGeometry, Float32BufferAttribute, type Group, Path, Shape } from 'three' import { BufferGeometry, Float32BufferAttribute, type Group, Path, Shape } from 'three'
import { useNodeEvents } from '../../../hooks/use-node-events' import { useNodeEvents } from '../../../hooks/use-node-events'
@@ -88,25 +87,17 @@ export const SiteRenderer = ({ node }: { node: SiteNode }) => {
for (let i = 1; i < pts.length; i++) shape.lineTo(pts[i]![0], -pts[i]![1]) for (let i = 1; i < pts.length; i++) shape.lineTo(pts[i]![0], -pts[i]![1])
shape.closePath() shape.closePath()
if (slabPolygons.length > 0) { for (const polygon of slabPolygons) {
const multiPolygons = slabPolygons.map((p) => [ if (polygon.length < 3) continue
p.map((pt) => [pt[0], -pt[1]] as [number, number]),
])
const unioned = polygonClipping.union(
multiPolygons[0] as polygonClipping.Polygon,
...(multiPolygons.slice(1) as polygonClipping.Polygon[]),
)
for (const geom of unioned) {
const ring = geom[0]
if (ring && ring.length > 0) {
const hole = new Path() const hole = new Path()
hole.moveTo(ring[0]![0], ring[0]![1]) hole.moveTo(polygon[0]![0], -polygon[0]![1])
for (let i = 1; i < ring.length; i++) hole.lineTo(ring[i]![0], ring[i]![1]) for (let i = 1; i < polygon.length; i++) {
hole.lineTo(polygon[i]![0], -polygon[i]![1])
}
hole.closePath() hole.closePath()
shape.holes.push(hole) shape.holes.push(hole)
} }
}
}
return shape return shape
}, [node?.polygon?.points, slabPolygons]) }, [node?.polygon?.points, slabPolygons])
@@ -11,6 +11,12 @@ import {
const slabMaterialCache = new Map<string, THREE.MeshStandardMaterial>() const slabMaterialCache = new Map<string, THREE.MeshStandardMaterial>()
function createEmptyGeometry() {
const geometry = new THREE.BufferGeometry()
geometry.setAttribute('position', new THREE.Float32BufferAttribute([], 3))
return geometry
}
function getSlabMaterial( function getSlabMaterial(
cacheKey: string, cacheKey: string,
params: { material?: SlabNode['material']; materialPreset?: string }, params: { material?: SlabNode['material']; materialPreset?: string },
@@ -47,11 +53,14 @@ function getSlabMaterial(
export const SlabRenderer = ({ node }: { node: SlabNode }) => { export const SlabRenderer = ({ node }: { node: SlabNode }) => {
const ref = useRef<Mesh>(null!) const ref = useRef<Mesh>(null!)
const placeholderGeometry = useMemo(createEmptyGeometry, [])
useRegistry(node.id, 'slab', ref) useRegistry(node.id, 'slab', ref)
const handlers = useNodeEvents(node, 'slab') const handlers = useNodeEvents(node, 'slab')
useEffect(() => () => placeholderGeometry.dispose(), [placeholderGeometry])
const material = useMemo(() => { const material = useMemo(() => {
const resolvedMaterial = node.material const resolvedMaterial = node.material
const resolvedMaterialPreset = node.materialPreset const resolvedMaterialPreset = node.materialPreset
@@ -75,13 +84,12 @@ export const SlabRenderer = ({ node }: { node: SlabNode }) => {
return ( return (
<mesh <mesh
castShadow castShadow
geometry={placeholderGeometry}
receiveShadow receiveShadow
ref={ref} ref={ref}
{...handlers} {...handlers}
material={material} material={material}
visible={node.visible} visible={node.visible}
> />
<boxGeometry args={[0, 0, 0]} />
</mesh>
) )
} }
@@ -1,12 +1,27 @@
import { useRegistry, useScene, type WallNode } from '@pascal-app/core' import { useRegistry, useScene, type WallNode } from '@pascal-app/core'
import { useLayoutEffect, useRef } from 'react' import { useEffect, useLayoutEffect, useMemo, useRef } from 'react'
import type { Mesh } from 'three' import { BufferGeometry, Float32BufferAttribute, type Mesh } from 'three'
import { useNodeEvents } from '../../../hooks/use-node-events' import { useNodeEvents } from '../../../hooks/use-node-events'
import { getVisibleWallMaterials } from '../../../systems/wall/wall-materials' import { getVisibleWallMaterials } from '../../../systems/wall/wall-materials'
import { NodeRenderer } from '../node-renderer' import { NodeRenderer } from '../node-renderer'
function createEmptyWallGeometry() {
const geometry = new BufferGeometry()
geometry.setAttribute('position', new Float32BufferAttribute([], 3))
geometry.addGroup(0, 0, 0)
geometry.addGroup(0, 0, 1)
geometry.addGroup(0, 0, 2)
return geometry
}
export const WallRenderer = ({ node }: { node: WallNode }) => { export const WallRenderer = ({ node }: { node: WallNode }) => {
const ref = useRef<Mesh>(null!) const ref = useRef<Mesh>(null!)
const placeholderGeometry = useMemo(createEmptyWallGeometry, [])
const collisionPlaceholderGeometry = useMemo(() => {
const geometry = new BufferGeometry()
geometry.setAttribute('position', new Float32BufferAttribute([], 3))
return geometry
}, [])
useRegistry(node.id, 'wall', ref) useRegistry(node.id, 'wall', ref)
@@ -14,15 +29,31 @@ export const WallRenderer = ({ node }: { node: WallNode }) => {
useScene.getState().markDirty(node.id) useScene.getState().markDirty(node.id)
}, [node.id]) }, [node.id])
useEffect(() => {
return () => {
placeholderGeometry.dispose()
collisionPlaceholderGeometry.dispose()
}
}, [collisionPlaceholderGeometry, placeholderGeometry])
const handlers = useNodeEvents(node, 'wall') const handlers = useNodeEvents(node, 'wall')
const material = getVisibleWallMaterials(node) const material = getVisibleWallMaterials(node)
return ( return (
<mesh castShadow material={material} receiveShadow ref={ref} visible={node.visible}> <mesh
<boxGeometry args={[0, 0, 0]} /> castShadow
<mesh name="collision-mesh" visible={false} {...handlers}> geometry={placeholderGeometry}
<boxGeometry args={[0, 0, 0]} /> material={material}
</mesh> receiveShadow
ref={ref}
visible={node.visible}
>
<mesh
geometry={collisionPlaceholderGeometry}
name="collision-mesh"
visible={false}
{...handlers}
/>
{node.children.map((childId) => ( {node.children.map((childId) => (
<NodeRenderer key={`${node.id}:${childId}`} nodeId={childId} /> <NodeRenderer key={`${node.id}:${childId}`} nodeId={childId} />
@@ -1,5 +1,4 @@
import { type LevelNode, useScene } from '@pascal-app/core' import { type LevelNode, useScene } from '@pascal-app/core'
import polygonClipping from 'polygon-clipping'
import { useMemo } from 'react' import { useMemo } from 'react'
import * as THREE from 'three' import * as THREE from 'three'
import useViewer from '../../store/use-viewer' import useViewer from '../../store/use-viewer'
@@ -63,34 +62,17 @@ export const GroundOccluder = () => {
polygons.push(node.polygon as [number, number][]) polygons.push(node.polygon as [number, number][])
}) })
if (polygons.length > 0) { for (const polygon of polygons) {
// Format for polygon-clipping: [[[x, y], [x, y], ...]] if (polygon.length < 3) continue
const multiPolygons = polygons.map((pts) => {
const ring = pts.map((p) => [p[0], -p[1]] as [number, number]) // Negate Y (which was Z)
return [ring]
})
// Union all polygons together to prevent artifacts from overlapping
const unionedPolygons = polygonClipping.union(multiPolygons[0]!, ...multiPolygons.slice(1))
// Add each resulting unioned polygon as a hole
for (const geom of unionedPolygons) {
// First ring in each geometry is the exterior ring
if (geom.length > 0) {
const ring = geom[0]!
const hole = new THREE.Path() const hole = new THREE.Path()
hole.moveTo(polygon[0]![0], -polygon[0]![1])
if (ring.length > 0) { for (let i = 1; i < polygon.length; i++) {
hole.moveTo(ring[0]![0], ring[0]![1]) hole.lineTo(polygon[i]![0], -polygon[i]![1])
for (let i = 1; i < ring.length; i++) {
hole.lineTo(ring[i]![0], ring[i]![1])
} }
hole.closePath() hole.closePath()
s.holes.push(hole) s.holes.push(hole)
} }
}
}
}
return s return s
}, [nodes]) }, [nodes])
@@ -1,6 +1,5 @@
'use client' 'use client'
import { Bvh } from '@react-three/drei'
import { Canvas, extend, type ThreeToJSXElements, useFrame, useThree } from '@react-three/fiber' import { Canvas, extend, type ThreeToJSXElements, useFrame, useThree } from '@react-three/fiber'
import { useEffect, useMemo, useRef } from 'react' import { useEffect, useMemo, useRef } from 'react'
import * as THREE from 'three/webgpu' import * as THREE from 'three/webgpu'
@@ -28,6 +27,7 @@ import FrameLimiter from './frame-limiter'
import { Lights } from './lights' import { Lights } from './lights'
import { PerfMonitor } from './perf-monitor' import { PerfMonitor } from './perf-monitor'
import PostProcessing, { DEFAULT_HOVER_STYLES, type HoverStyles } from './post-processing' import PostProcessing, { DEFAULT_HOVER_STYLES, type HoverStyles } from './post-processing'
import { SceneBvh } from './scene-bvh'
import { SelectionManager } from './selection-manager' import { SelectionManager } from './selection-manager'
import { ViewerCamera } from './viewer-camera' import { ViewerCamera } from './viewer-camera'
@@ -219,9 +219,9 @@ const Viewer: React.FC<ViewerProps> = ({
/> */} /> */}
<Lights /> <Lights />
{useBvh ? ( {useBvh ? (
<Bvh> <SceneBvh>
<SceneRenderer /> <SceneRenderer />
</Bvh> </SceneBvh>
) : ( ) : (
<SceneRenderer /> <SceneRenderer />
)} )}
@@ -0,0 +1,137 @@
import { useThree } from '@react-three/fiber'
import {
type ReactNode,
forwardRef,
useEffect,
useImperativeHandle,
useRef,
} from 'react'
import { Group, Mesh, type BufferGeometry } from 'three'
import {
SAH,
acceleratedRaycast,
computeBoundsTree,
disposeBoundsTree,
type SplitStrategy,
} from 'three-mesh-bvh'
type SceneBvhProps = {
children?: ReactNode
enabled?: boolean
firstHitOnly?: boolean
strategy?: SplitStrategy
verbose?: boolean
setBoundingBox?: boolean
maxDepth?: number
maxLeafSize?: number
indirect?: boolean
}
const isMesh = (object: unknown): object is Mesh =>
!!object && typeof object === 'object' && (object as Mesh).isMesh === true
const hasBvhCompatibleGeometry = (geometry?: BufferGeometry | null) => {
if (!geometry) return false
const position = geometry.getAttribute('position')
if (!position) return false
const vertexCount = geometry.getIndex()?.count ?? position.count
return vertexCount >= 3
}
export const SceneBvh = forwardRef<Group, SceneBvhProps>(
(
{
children,
enabled = true,
firstHitOnly = false,
strategy = SAH,
verbose = false,
setBoundingBox = true,
maxDepth = 40,
maxLeafSize = 10,
indirect = false,
},
forwardedRef,
) => {
const ref = useRef<Group>(null)
const raycaster = useThree((state) => state.raycaster)
useImperativeHandle(forwardedRef, () => ref.current!, [])
useEffect(() => {
if (!enabled || !ref.current) return
const options = {
strategy,
verbose,
setBoundingBox,
maxDepth,
maxLeafSize,
indirect,
}
const group = ref.current
const acceleratedMeshes = new Set<Mesh>()
const computedGeometries = new Set<BufferGeometry>()
;(raycaster as any).firstHitOnly = firstHitOnly
group.traverse((child) => {
if (!isMesh(child)) return
if (child.raycast === Mesh.prototype.raycast) {
child.raycast = acceleratedRaycast
acceleratedMeshes.add(child)
}
if (child.raycast !== acceleratedRaycast) return
const geometry = child.geometry
if (geometry.boundsTree || !hasBvhCompatibleGeometry(geometry)) return
try {
geometry.computeBoundsTree = computeBoundsTree
geometry.disposeBoundsTree = disposeBoundsTree
geometry.computeBoundsTree(options)
computedGeometries.add(geometry)
} catch (error) {
console.warn('[viewer] Skipping BVH for incompatible mesh geometry.', {
mesh: child.name || child.type,
error,
})
}
})
return () => {
delete (raycaster as any).firstHitOnly
for (const geometry of computedGeometries) {
if (geometry.boundsTree) {
geometry.disposeBoundsTree()
}
}
for (const mesh of acceleratedMeshes) {
if (mesh.raycast === acceleratedRaycast) {
mesh.raycast = Mesh.prototype.raycast
}
}
}
}, [
enabled,
firstHitOnly,
strategy,
verbose,
setBoundingBox,
maxDepth,
maxLeafSize,
indirect,
raycaster,
])
return <group ref={ref}>{children}</group>
},
)
SceneBvh.displayName = 'SceneBvh'
+6 -4
View File
@@ -282,16 +282,18 @@ export function createMaterial(material?: MaterialSchema): THREE.MeshStandardMat
} }
const map = getTexture(material) const map = getTexture(material)
const materialParams: THREE.MeshStandardMaterialParameters = {
const threeMaterial = new THREE.MeshStandardMaterial({
color: props.color, color: props.color,
roughness: props.roughness, roughness: props.roughness,
metalness: props.metalness, metalness: props.metalness,
opacity: props.opacity, opacity: props.opacity,
transparent: props.transparent, transparent: props.transparent,
side: sideMap[props.side], side: sideMap[props.side],
map, }
})
if (map) materialParams.map = map
const threeMaterial = new THREE.MeshStandardMaterial(materialParams)
materialCache.set(cacheKey, threeMaterial) materialCache.set(cacheKey, threeMaterial)
return threeMaterial return threeMaterial
@@ -50,7 +50,7 @@ function updateCeilingGeometry(node: CeilingNode, mesh: THREE.Mesh) {
const gridMesh = mesh.getObjectByName('ceiling-grid') as THREE.Mesh const gridMesh = mesh.getObjectByName('ceiling-grid') as THREE.Mesh
if (gridMesh) { if (gridMesh) {
gridMesh.geometry.dispose() gridMesh.geometry.dispose()
gridMesh.geometry = newGeo gridMesh.geometry = newGeo.clone()
} }
// Position at the ceiling height // Position at the ceiling height
@@ -166,6 +166,7 @@ function createFenceParts(fence: FenceNode): FencePart[] {
const spacing = Math.max(fence.postSpacing * styleDefaults.spacingFactor, postWidth * 1.2) const spacing = Math.max(fence.postSpacing * styleDefaults.spacingFactor, postWidth * 1.2)
const edgeInset = Math.max(fence.edgeInset ?? 0.015, 0.005) const edgeInset = Math.max(fence.edgeInset ?? 0.015, 0.005)
const isFloating = fence.baseStyle === 'floating' const isFloating = fence.baseStyle === 'floating'
const showInfill = fence.showInfill ?? true
const baseY = isFloating ? clearance : 0 const baseY = isFloating ? clearance : 0
const effectiveBaseHeight = baseHeight const effectiveBaseHeight = baseHeight
const startInsetT = Math.min(0.499, edgeInset / length) const startInsetT = Math.min(0.499, edgeInset / length)
@@ -194,18 +195,18 @@ function createFenceParts(fence: FenceNode): FencePart[] {
) )
} }
const count = Math.max(2, Math.floor((length - edgeInset * 2) / spacing) + 1) const count = showInfill ? Math.max(2, Math.floor((length - edgeInset * 2) / spacing) + 1) : 2
const verticalY = baseY + effectiveBaseHeight + verticalHeight / 2 const verticalY = baseY + effectiveBaseHeight + verticalHeight / 2
for (let index = 0; index < count; index += 1) { for (let index = 0; index < count; index += 1) {
const t = count === 1 ? 0.5 : startInsetT + (endInsetT - startInsetT) * (index / (count - 1)) const t = count === 1 ? 0.5 : startInsetT + (endInsetT - startInsetT) * (index / (count - 1))
const frame = getFencePointAt(fence, t) const frame = getFencePointAt(fence, t)
const isEdgePost = index === 0 || index === count - 1 const isEdgePost = index === 0 || index === count - 1
const postHeight = const fullHeightPost = !showInfill || (isFloating && isEdgePost)
isFloating && isEdgePost const postHeight = fullHeightPost
? effectiveBaseHeight + verticalHeight + topRailHeight + clearance ? effectiveBaseHeight + verticalHeight + topRailHeight + clearance
: verticalHeight : verticalHeight
const postY = isFloating && isEdgePost ? postHeight / 2 : verticalY const postY = fullHeightPost ? postHeight / 2 : verticalY
parts.push({ parts.push({
position: [frame.point.x, postY, frame.point.y], position: [frame.point.x, postY, frame.point.y],
@@ -246,7 +247,9 @@ function generateFenceGeometry(fence: FenceNode) {
const geometries = parts.map(createFencePartGeometry) const geometries = parts.map(createFencePartGeometry)
const merged = mergeGeometries(geometries, false) ?? new THREE.BufferGeometry() const merged = mergeGeometries(geometries, false) ?? new THREE.BufferGeometry()
geometries.forEach((geometry) => geometry.dispose()) geometries.forEach((geometry) => {
geometry.dispose()
})
const mergedUv = merged.getAttribute('uv') const mergedUv = merged.getAttribute('uv')
if (mergedUv) { if (mergedUv) {
merged.setAttribute('uv2', new THREE.Float32BufferAttribute(Array.from(mergedUv.array), 2)) merged.setAttribute('uv2', new THREE.Float32BufferAttribute(Array.from(mergedUv.array), 2))
@@ -6,6 +6,7 @@ import {
useScene, useScene,
} from '@pascal-app/core' } from '@pascal-app/core'
import { useFrame } from '@react-three/fiber' import { useFrame } from '@react-three/fiber'
import { useEffect } from 'react'
import * as THREE from 'three' import * as THREE from 'three'
function ensureUv2Attribute(geometry: THREE.BufferGeometry) { function ensureUv2Attribute(geometry: THREE.BufferGeometry) {
@@ -22,6 +23,16 @@ function ensureUv2Attribute(geometry: THREE.BufferGeometry) {
export const SlabSystem = () => { export const SlabSystem = () => {
const dirtyNodes = useScene((state) => state.dirtyNodes) const dirtyNodes = useScene((state) => state.dirtyNodes)
const clearDirty = useScene((state) => state.clearDirty) const clearDirty = useScene((state) => state.clearDirty)
const markDirty = useScene((state) => state.markDirty)
useEffect(() => {
const nodes = useScene.getState().nodes
for (const node of Object.values(nodes)) {
if (node.type === 'slab') {
markDirty(node.id)
}
}
}, [markDirty])
useFrame(() => { useFrame(() => {
if (dirtyNodes.size === 0) return if (dirtyNodes.size === 0) return