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:
@@ -1,14 +1,12 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
Editor,
|
||||
ItemsPanel,
|
||||
type SidebarTab,
|
||||
ViewerToolbarLeft,
|
||||
ViewerToolbarRight,
|
||||
} from '@pascal-app/editor'
|
||||
import { Editor, ItemsPanel } from '@pascal-app/editor'
|
||||
import { Layers, Package, Settings } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import {
|
||||
CommunityViewerToolbarLeft,
|
||||
CommunityViewerToolbarRight,
|
||||
} from '@/components/viewer-toolbar'
|
||||
|
||||
const SIDEBAR_TABS = [
|
||||
{
|
||||
@@ -59,8 +57,8 @@ export default function Home() {
|
||||
layoutVersion="v2"
|
||||
projectId={PROJECT_ID}
|
||||
sidebarTabs={SIDEBAR_TABS}
|
||||
viewerToolbarLeft={<ViewerToolbarLeft />}
|
||||
viewerToolbarRight={<ViewerToolbarRight />}
|
||||
viewerToolbarLeft={<CommunityViewerToolbarLeft />}
|
||||
viewerToolbarRight={<CommunityViewerToolbarRight />}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -5,12 +5,11 @@ import {
|
||||
Editor,
|
||||
type SceneGraph,
|
||||
type SidebarTab,
|
||||
ViewerToolbarLeft,
|
||||
ViewerToolbarRight,
|
||||
} from '@pascal-app/editor'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { CommunityViewerToolbarLeft, CommunityViewerToolbarRight } from './viewer-toolbar'
|
||||
|
||||
export interface SceneMeta {
|
||||
id: string
|
||||
@@ -200,8 +199,8 @@ export function SceneLoader({ initialScene, meta }: SceneLoaderProps) {
|
||||
onThumbnailCapture={handleThumb}
|
||||
projectId={meta.projectId ?? 'default'}
|
||||
sidebarTabs={SIDEBAR_TABS}
|
||||
viewerToolbarLeft={<ViewerToolbarLeft />}
|
||||
viewerToolbarRight={<ViewerToolbarRight />}
|
||||
viewerToolbarLeft={<CommunityViewerToolbarLeft />}
|
||||
viewerToolbarRight={<CommunityViewerToolbarRight />}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
'use client'
|
||||
|
||||
import * as TooltipPrimitive from '@radix-ui/react-tooltip'
|
||||
import type * as React from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function TooltipProvider({
|
||||
delayDuration = 0,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
|
||||
return <TooltipPrimitive.Provider delayDuration={delayDuration} {...props} />
|
||||
}
|
||||
|
||||
function Tooltip({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<TooltipPrimitive.Root {...props} />
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function TooltipTrigger({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||
return <TooltipPrimitive.Trigger {...props} />
|
||||
}
|
||||
|
||||
function TooltipContent({
|
||||
className,
|
||||
sideOffset = 6,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
className={cn(
|
||||
'fade-in-0 zoom-in-95 data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) animate-in rounded-md bg-foreground px-3 py-1.5 text-background text-xs data-[state=closed]:animate-out',
|
||||
className,
|
||||
)}
|
||||
sideOffset={sideOffset}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground" />
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -11,11 +11,13 @@
|
||||
"check-types": "next typegen && tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@iconify/react": "^6.0.2",
|
||||
"@number-flow/react": "^0.5.14",
|
||||
"@pascal-app/core": "*",
|
||||
"@pascal-app/editor": "*",
|
||||
"@pascal-app/mcp": "*",
|
||||
"@pascal-app/viewer": "*",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@react-three/drei": "^10.7.7",
|
||||
"@react-three/fiber": "^9.5.0",
|
||||
"@tailwindcss/postcss": "^4.2.1",
|
||||
|
||||
@@ -26,11 +26,13 @@
|
||||
"name": "editor",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@iconify/react": "^6.0.2",
|
||||
"@number-flow/react": "^0.5.14",
|
||||
"@pascal-app/core": "*",
|
||||
"@pascal-app/editor": "*",
|
||||
"@pascal-app/mcp": "*",
|
||||
"@pascal-app/viewer": "*",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@react-three/drei": "^10.7.7",
|
||||
"@react-three/fiber": "^9.5.0",
|
||||
"@tailwindcss/postcss": "^4.2.1",
|
||||
@@ -205,7 +207,6 @@
|
||||
"name": "@pascal-app/viewer",
|
||||
"version": "0.8.0",
|
||||
"dependencies": {
|
||||
"polygon-clipping": "^0.15.7",
|
||||
"three-bvh-csg": "^0.0.18",
|
||||
"three-mesh-bvh": "^0.9.8",
|
||||
"zustand": "^5",
|
||||
@@ -1260,8 +1261,6 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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.js": ["stats.js@0.17.0", "", {}, "sha512-hNKz8phvYLPEcRkeG1rsGmV5ChMjKDAWU7/OJJdDErPBNChQXxCo3WZurGpnWc6gZhAzEPFad1aVgyOANH1sMw=="],
|
||||
|
||||
@@ -45,6 +45,8 @@ export { getRenderableSlabPolygon } from './lib/slab-polygon'
|
||||
export {
|
||||
detectSpacesForLevel,
|
||||
initSpaceDetectionSync,
|
||||
planAutoSlabsForLevel,
|
||||
type AutoSlabSyncPlan,
|
||||
type Space,
|
||||
wallTouchesOthers,
|
||||
} from './lib/space-detection'
|
||||
@@ -107,6 +109,15 @@ export {
|
||||
type WallMiterBoundaryPoints,
|
||||
type WallMiterData,
|
||||
} 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 { cloneLevelSubtree, cloneSceneGraph, forkSceneGraph } from './utils/clone-scene-graph'
|
||||
export { isObject } from './utils/types'
|
||||
|
||||
@@ -41,6 +41,12 @@ type DetectedRoom = {
|
||||
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_CEILING_HEIGHT = 2.5
|
||||
const ROOM_CURVE_TOLERANCE = 0.04
|
||||
@@ -488,12 +494,10 @@ function buildSpace(levelId: string, polygon: Point2D[]): Space {
|
||||
}
|
||||
}
|
||||
|
||||
function syncAutoSlabsForLevel(
|
||||
levelId: string,
|
||||
export function planAutoSlabsForLevel(
|
||||
roomPolygons: Point2D[][],
|
||||
existingSlabs: SlabNodeType[],
|
||||
sceneStore: any,
|
||||
) {
|
||||
): AutoSlabSyncPlan {
|
||||
const manualSlabs = existingSlabs.filter((slab) => !slab.autoFromWalls)
|
||||
const manualSignatures = new Set(
|
||||
manualSlabs.map((slab) => polygonSignature(slab.polygon.map(pointFromTuple))),
|
||||
@@ -618,16 +622,31 @@ function syncAutoSlabsForLevel(
|
||||
)
|
||||
}
|
||||
|
||||
if (slabsToDelete.length > 0) {
|
||||
sceneStore.getState().deleteNodes(slabsToDelete)
|
||||
return {
|
||||
create: slabsToCreate,
|
||||
update: slabsToUpdate,
|
||||
delete: slabsToDelete,
|
||||
}
|
||||
}
|
||||
|
||||
function syncAutoSlabsForLevel(
|
||||
levelId: string,
|
||||
roomPolygons: Point2D[][],
|
||||
existingSlabs: SlabNodeType[],
|
||||
sceneStore: any,
|
||||
) {
|
||||
const plan = planAutoSlabsForLevel(roomPolygons, existingSlabs)
|
||||
|
||||
if (plan.delete.length > 0) {
|
||||
sceneStore.getState().deleteNodes(plan.delete)
|
||||
}
|
||||
|
||||
if (slabsToUpdate.length > 0) {
|
||||
sceneStore.getState().updateNodes(slabsToUpdate)
|
||||
if (plan.update.length > 0) {
|
||||
sceneStore.getState().updateNodes(plan.update)
|
||||
}
|
||||
|
||||
if (slabsToCreate.length > 0) {
|
||||
sceneStore.getState().createNodes(slabsToCreate.map((node) => ({ node, parentId: levelId })))
|
||||
if (plan.create.length > 0) {
|
||||
sceneStore.getState().createNodes(plan.create.map((node) => ({ node, parentId: levelId })))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ export {
|
||||
ColumnShaftDetail,
|
||||
ColumnShaftProfile,
|
||||
ColumnStyle,
|
||||
ColumnSupportStyle,
|
||||
} from './nodes/column'
|
||||
export { DoorNode, DoorSegment } from './nodes/door'
|
||||
export { FenceBaseStyle, FenceNode, FenceStyle } from './nodes/fence'
|
||||
|
||||
@@ -56,6 +56,20 @@ export const ColumnRingPlacement = z.enum(['ends', 'even', 'top', 'bottom'])
|
||||
|
||||
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 ColumnCrossSection = z.infer<typeof ColumnCrossSection>
|
||||
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 ColumnRingPlacement = z.infer<typeof ColumnRingPlacement>
|
||||
export type ColumnCarvingPlacement = z.infer<typeof ColumnCarvingPlacement>
|
||||
export type ColumnSupportStyle = z.infer<typeof ColumnSupportStyle>
|
||||
|
||||
export const ColumnNode = BaseNode.extend({
|
||||
id: objectId('column'),
|
||||
@@ -73,7 +88,7 @@ export const ColumnNode = BaseNode.extend({
|
||||
rotation: z.number().default(0),
|
||||
style: ColumnStyle.default('plain'),
|
||||
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),
|
||||
width: 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),
|
||||
dentilCount: z.number().int().min(0).max(48).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(),
|
||||
materialPreset: z.string().optional(),
|
||||
}).describe(dedent`
|
||||
@@ -150,6 +171,7 @@ export const ColumnNode = BaseNode.extend({
|
||||
- baseStyle/capitalStyle: procedural base and top treatment with tier/detail controls
|
||||
- baseHeight/capitalHeight: bottom and top block proportions
|
||||
- ring/flute/spiral/panel/lathe/carving fields: procedural detail controls
|
||||
- supportStyle/brace fields: vertical column or procedural support assembly
|
||||
`)
|
||||
|
||||
export const COLUMN_PRESETS = {
|
||||
@@ -157,7 +179,7 @@ export const COLUMN_PRESETS = {
|
||||
label: 'Straight Round',
|
||||
style: 'plain',
|
||||
crossSection: 'round',
|
||||
height: 2.9,
|
||||
height: 2.5,
|
||||
radius: 0.22,
|
||||
width: 0.44,
|
||||
depth: 0.44,
|
||||
@@ -207,7 +229,7 @@ export const COLUMN_PRESETS = {
|
||||
label: 'Square Block',
|
||||
style: 'faceted',
|
||||
crossSection: 'square',
|
||||
height: 2.9,
|
||||
height: 2.5,
|
||||
radius: 0.24,
|
||||
width: 0.48,
|
||||
depth: 0.48,
|
||||
@@ -257,7 +279,7 @@ export const COLUMN_PRESETS = {
|
||||
label: 'Tapered Round',
|
||||
style: 'plain',
|
||||
crossSection: 'round',
|
||||
height: 3,
|
||||
height: 2.5,
|
||||
radius: 0.23,
|
||||
width: 0.46,
|
||||
depth: 0.46,
|
||||
@@ -307,7 +329,7 @@ export const COLUMN_PRESETS = {
|
||||
label: 'Soft Bulged',
|
||||
style: 'plain',
|
||||
crossSection: 'round',
|
||||
height: 2.9,
|
||||
height: 2.5,
|
||||
radius: 0.22,
|
||||
width: 0.44,
|
||||
depth: 0.44,
|
||||
@@ -357,7 +379,7 @@ export const COLUMN_PRESETS = {
|
||||
label: 'Hourglass',
|
||||
style: 'plain',
|
||||
crossSection: 'round',
|
||||
height: 2.9,
|
||||
height: 2.5,
|
||||
radius: 0.22,
|
||||
width: 0.44,
|
||||
depth: 0.44,
|
||||
@@ -403,6 +425,566 @@ export const COLUMN_PRESETS = {
|
||||
dentilCount: 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>>>
|
||||
|
||||
export type ColumnPresetId = keyof typeof COLUMN_PRESETS
|
||||
|
||||
@@ -23,6 +23,7 @@ export const FenceNode = BaseNode.extend({
|
||||
groundClearance: z.number().default(0),
|
||||
edgeInset: z.number().default(0.015),
|
||||
baseStyle: FenceBaseStyle.default('grounded'),
|
||||
showInfill: z.boolean().default(true),
|
||||
color: z.string().default('#ffffff'),
|
||||
style: FenceStyle.default('slat'),
|
||||
}).describe(
|
||||
@@ -33,6 +34,7 @@ export const FenceNode = BaseNode.extend({
|
||||
- curveOffset: midpoint sagitta offset used to bend the fence into an arc
|
||||
- baseHeight/postSpacing/postSize/topRailHeight: exact geometric controls from the plan3D fence model
|
||||
- groundClearance/edgeInset/baseStyle: fence support and inset configuration
|
||||
- showInfill: whether to draw intermediate posts/slats between end posts
|
||||
- color/style: visual appearance options
|
||||
`,
|
||||
)
|
||||
|
||||
@@ -9,6 +9,9 @@ import type { CollectionId } from '../../schema/collections'
|
||||
import type { SceneState } from '../use-scene'
|
||||
|
||||
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 WallMergePlan = {
|
||||
primaryWallId: AnyNodeId
|
||||
@@ -232,7 +235,7 @@ function buildWallMergePlans(
|
||||
export const createNodesAction = (
|
||||
set: (fn: (state: SceneState) => Partial<SceneState>) => void,
|
||||
get: () => SceneState,
|
||||
ops: { node: AnyNode; parentId?: AnyNodeId }[],
|
||||
ops: NodeCreateOp[],
|
||||
) => {
|
||||
if (get().readOnly) return
|
||||
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 = (
|
||||
set: (fn: (state: SceneState) => Partial<SceneState>) => void,
|
||||
get: () => SceneState,
|
||||
|
||||
@@ -438,6 +438,11 @@ export type SceneState = {
|
||||
|
||||
createNode: (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
|
||||
updateNodes: (updates: { id: AnyNodeId; data: Partial<AnyNode> }[]) => void
|
||||
@@ -586,6 +591,7 @@ const useScene: UseSceneStore = create<SceneState>()(
|
||||
|
||||
createNodes: (ops) => nodeActions.createNodesAction(set, get, ops),
|
||||
createNode: (node, parentId) => nodeActions.createNodesAction(set, get, [{ node, parentId }]),
|
||||
applyNodeChanges: (changes) => nodeActions.applyNodeChangesAction(set, get, changes),
|
||||
|
||||
updateNodes: (updates) => nodeActions.updateNodesAction(set, get, updates),
|
||||
updateNode: (id, data) => nodeActions.updateNodesAction(set, get, [{ id, data }]),
|
||||
|
||||
@@ -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
|
||||
onMove: FloorplanActionMenuHandler
|
||||
onAddHole?: FloorplanActionMenuHandler
|
||||
onCurve?: FloorplanActionMenuHandler
|
||||
onDuplicate?: FloorplanActionMenuHandler
|
||||
}
|
||||
|
||||
@@ -81,6 +82,7 @@ export const FloorplanActionMenuLayer = memo(function FloorplanActionMenuLayer({
|
||||
>
|
||||
<NodeActionMenu
|
||||
onAddHole={entry.onAddHole}
|
||||
onCurve={entry.onCurve}
|
||||
onDelete={entry.onDelete}
|
||||
onDuplicate={entry.onDuplicate}
|
||||
onMove={entry.onMove}
|
||||
|
||||
@@ -434,7 +434,11 @@ export function FloatingActionMenu() {
|
||||
? handleDuplicate
|
||||
: 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()}
|
||||
onPointerUp={(e) => e.stopPropagation()}
|
||||
/>
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
sceneRegistry,
|
||||
useLiveTransforms,
|
||||
useScene,
|
||||
WallNode as WallNodeSchema,
|
||||
type WallNode,
|
||||
WindowNode,
|
||||
ZoneNode as ZoneNodeSchema,
|
||||
@@ -625,6 +626,12 @@ type FloorplanSpawnEntry = {
|
||||
rotation: number
|
||||
}
|
||||
|
||||
type FloorplanColumnEntry = {
|
||||
column: ColumnNode
|
||||
points: string
|
||||
polygon: Point2D[]
|
||||
}
|
||||
|
||||
type ReferenceFloorData = {
|
||||
ceilingPolygons: CeilingPolygonEntry[]
|
||||
columnEntries: ReferenceFloorColumnEntry[]
|
||||
@@ -1768,6 +1775,56 @@ function getRotatedRectanglePolygon(
|
||||
|
||||
function getColumnPlanFootprint(column: ColumnNode): Point2D[] {
|
||||
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 =
|
||||
column.crossSection === 'round' ||
|
||||
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 fenceUnderlayWidth = isActive ? '6.5' : isHovered ? '6' : '5.2'
|
||||
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 privacyMarkerHeight = clamp(
|
||||
Math.max(fence.baseHeight * 0.5, fence.postSize * 1.4),
|
||||
@@ -5792,7 +5855,7 @@ const FloorplanFenceLayer = memo(function FloorplanFenceLayer({
|
||||
strokeWidth={fenceStrokeWidth}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
{markerFrames.map(({ angleDeg, point }, markerIndex) => {
|
||||
{visibleMarkerFrames.map(({ angleDeg, point }, markerIndex) => {
|
||||
const svgPoint = toSvgPoint(point)
|
||||
|
||||
if (fence.style === 'privacy') {
|
||||
@@ -7492,6 +7555,7 @@ export function FloorplanPanel() {
|
||||
const setPhase = useEditor((state) => state.setPhase)
|
||||
const setMovingFenceEndpoint = useEditor((state) => state.setMovingFenceEndpoint)
|
||||
const setMovingNode = useEditor((state) => state.setMovingNode)
|
||||
const setCurvingWall = useEditor((state) => state.setCurvingWall)
|
||||
const movingFenceEndpoint = useEditor((state) => state.movingFenceEndpoint)
|
||||
const structureLayer = useEditor((state) => state.structureLayer)
|
||||
const setStructureLayer = useEditor((state) => state.setStructureLayer)
|
||||
@@ -8140,6 +8204,28 @@ export function FloorplanPanel() {
|
||||
: entry,
|
||||
)
|
||||
}, [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(
|
||||
() => new Map(levelDescendantNodes.map((node) => [node.id, node] as const)),
|
||||
[levelDescendantNodes],
|
||||
@@ -9240,6 +9326,7 @@ export function FloorplanPanel() {
|
||||
selectedWallEntry,
|
||||
wallCurveDraft,
|
||||
])
|
||||
const canCurveSelectedWall = wallCurveHandles.length > 0
|
||||
const slabVertexHandles = useMemo(() => {
|
||||
if (!shouldShowSlabBoundaryHandles) {
|
||||
return []
|
||||
@@ -12967,6 +13054,7 @@ export function FloorplanPanel() {
|
||||
)
|
||||
const { getFloorplanHitIdAtPoint, getFloorplanSelectionIdsInBounds } = useFloorplanHitTesting({
|
||||
ceilingPolygons: displayCeilingPolygons,
|
||||
columnPolygons: floorplanColumnEntries,
|
||||
displaySlabPolygons,
|
||||
displayWallPolygons,
|
||||
floorplanItemEntries,
|
||||
@@ -13981,6 +14069,57 @@ export function FloorplanPanel() {
|
||||
},
|
||||
[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(
|
||||
(event: ReactMouseEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation()
|
||||
@@ -16020,9 +16159,17 @@ export function FloorplanPanel() {
|
||||
site,
|
||||
])
|
||||
const hasDuplicatableFloorplanSelection = Boolean(
|
||||
selectedItemEntry || selectedOpeningEntry || selectedStairEntry || selectedRoofEntry,
|
||||
selectedItemEntry ||
|
||||
selectedOpeningEntry ||
|
||||
selectedStairEntry ||
|
||||
selectedRoofEntry ||
|
||||
selectedWallEntry,
|
||||
)
|
||||
const handleDuplicateFloorplanSelection = useCallback(() => {
|
||||
if (selectedWallEntry) {
|
||||
duplicateSelectedWall()
|
||||
return
|
||||
}
|
||||
if (selectedOpeningEntry) {
|
||||
duplicateSelectedOpening()
|
||||
return
|
||||
@@ -16039,6 +16186,7 @@ export function FloorplanPanel() {
|
||||
duplicateSelectedRoof()
|
||||
}
|
||||
}, [
|
||||
duplicateSelectedWall,
|
||||
duplicateSelectedItem,
|
||||
duplicateSelectedOpening,
|
||||
duplicateSelectedRoof,
|
||||
@@ -16047,6 +16195,7 @@ export function FloorplanPanel() {
|
||||
selectedOpeningEntry,
|
||||
selectedRoofEntry,
|
||||
selectedStairEntry,
|
||||
selectedWallEntry,
|
||||
])
|
||||
const activeDraftAnchorPoint =
|
||||
referenceScaleDraft?.start ??
|
||||
@@ -16173,7 +16322,9 @@ export function FloorplanPanel() {
|
||||
}}
|
||||
wall={{
|
||||
position: selectedWallActionMenuPosition,
|
||||
onCurve: canCurveSelectedWall ? handleSelectedWallCurve : undefined,
|
||||
onDelete: handleSelectedWallDelete,
|
||||
onDuplicate: handleSelectedWallDuplicate,
|
||||
onMove: handleSelectedWallMove,
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -68,6 +68,7 @@ import { SiteEdgeLabels } from './site-edge-labels'
|
||||
import { SnapshotCaptureOverlay } from './snapshot-capture-overlay'
|
||||
import { type SnapshotCameraData, ThumbnailGenerator } from './thumbnail-generator'
|
||||
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 DELETE_CURSOR_BADGE_COLOR = '#ef4444'
|
||||
@@ -587,6 +588,7 @@ const ViewerSceneContent = memo(function ViewerSceneContent({
|
||||
<>
|
||||
{!isFirstPersonMode && <SelectionManager />}
|
||||
{!(isVersionPreviewMode || isFirstPersonMode) && <BoxSelectTool />}
|
||||
{!(isVersionPreviewMode || isFirstPersonMode) && <WallMoveSideHandles />}
|
||||
{!(isVersionPreviewMode || isFirstPersonMode) && <FloatingActionMenu />}
|
||||
{!(isVersionPreviewMode || isFirstPersonMode) && <FloatingBuildingActionMenu />}
|
||||
{!isFirstPersonMode && <WallMeasurementLabel />}
|
||||
|
||||
@@ -1636,6 +1636,7 @@ const EditorOutlinerSync = () => {
|
||||
const previewSelectedIds = useViewer((s) => s.previewSelectedIds)
|
||||
const hoveredId = useViewer((s) => s.hoveredId)
|
||||
const outliner = useViewer((s) => s.outliner)
|
||||
const nodes = useScene((s) => s.nodes)
|
||||
|
||||
useEffect(() => {
|
||||
let idsToHighlight: string[] = []
|
||||
@@ -1672,16 +1673,21 @@ const EditorOutlinerSync = () => {
|
||||
// 2. Sync with the imperative outliner arrays (mutate in place to keep references)
|
||||
outliner.selectedObjects.length = 0
|
||||
for (const id of idsToHighlight) {
|
||||
if (!nodes[id as AnyNodeId]) continue
|
||||
const obj = sceneRegistry.nodes.get(id)
|
||||
if (obj?.parent) outliner.selectedObjects.push(obj)
|
||||
}
|
||||
|
||||
outliner.hoveredObjects.length = 0
|
||||
if (hoveredId) {
|
||||
const obj = sceneRegistry.nodes.get(hoveredId)
|
||||
if (obj?.parent) outliner.hoveredObjects.push(obj)
|
||||
if (!nodes[hoveredId as AnyNodeId]) {
|
||||
useViewer.setState({ hoveredId: null })
|
||||
} else {
|
||||
const obj = sceneRegistry.nodes.get(hoveredId)
|
||||
if (obj?.parent) outliner.hoveredObjects.push(obj)
|
||||
}
|
||||
}
|
||||
}, [phase, previewSelectedIds, selection, hoveredId, outliner])
|
||||
}, [phase, previewSelectedIds, selection, hoveredId, outliner, nodes])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import type {
|
||||
AnyNode,
|
||||
CeilingNode,
|
||||
ColumnNode,
|
||||
DoorNode,
|
||||
ItemNode,
|
||||
Point2D,
|
||||
@@ -46,6 +47,11 @@ type CeilingPolygonEntry = {
|
||||
holes: Point2D[][]
|
||||
}
|
||||
|
||||
type ColumnPolygonEntry = {
|
||||
column: ColumnNode
|
||||
polygon: Point2D[]
|
||||
}
|
||||
|
||||
type FloorplanRoofEntry = {
|
||||
roof: RoofNode
|
||||
segments: Array<{
|
||||
@@ -72,6 +78,7 @@ type FloorplanStairEntry = {
|
||||
|
||||
type UseFloorplanHitTestingArgs = {
|
||||
ceilingPolygons: CeilingPolygonEntry[]
|
||||
columnPolygons: ColumnPolygonEntry[]
|
||||
displaySlabPolygons: SlabPolygonEntry[]
|
||||
displayWallPolygons: WallPolygonEntry[]
|
||||
floorplanItemEntries: FloorplanItemEntry[]
|
||||
@@ -88,6 +95,7 @@ type UseFloorplanHitTestingArgs = {
|
||||
|
||||
export function useFloorplanHitTesting({
|
||||
ceilingPolygons,
|
||||
columnPolygons,
|
||||
displaySlabPolygons,
|
||||
displayWallPolygons,
|
||||
floorplanItemEntries,
|
||||
@@ -117,11 +125,13 @@ export function useFloorplanHitTesting({
|
||||
slabs: displaySlabPolygons,
|
||||
openingHitTolerance: floorplanOpeningHitTolerance,
|
||||
wallHitTolerance: floorplanWallHitTolerance,
|
||||
columns: columnPolygons,
|
||||
getOpeningCenterLine,
|
||||
})
|
||||
},
|
||||
[
|
||||
ceilingPolygons,
|
||||
columnPolygons,
|
||||
displaySlabPolygons,
|
||||
displayWallPolygons,
|
||||
floorplanItemEntries,
|
||||
@@ -149,10 +159,12 @@ export function useFloorplanHitTesting({
|
||||
openings: openingsPolygons,
|
||||
roofs: floorplanRoofEntries,
|
||||
slabs: displaySlabPolygons,
|
||||
columns: columnPolygons,
|
||||
stairs: floorplanStairEntries,
|
||||
}),
|
||||
[
|
||||
ceilingPolygons,
|
||||
columnPolygons,
|
||||
displaySlabPolygons,
|
||||
displayWallPolygons,
|
||||
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 type { Group } from 'three'
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
import useEditor from '../../../store/use-editor'
|
||||
import { CursorSphere } from '../shared/cursor-sphere'
|
||||
|
||||
const COLUMN_ICON = (
|
||||
@@ -70,8 +69,6 @@ export const ColumnTool: React.FC<ColumnToolProps> = ({ currentLevelId, onPlaced
|
||||
useScene.getState().createNode(column, currentLevelId)
|
||||
onPlaced?.(column.id)
|
||||
sfxEmitter.emit('sfx:structure-build')
|
||||
useEditor.getState().setTool(null)
|
||||
useEditor.getState().setMode('select')
|
||||
}
|
||||
|
||||
emitter.on('grid:move', onGridMove)
|
||||
@@ -88,7 +85,7 @@ export const ColumnTool: React.FC<ColumnToolProps> = ({ currentLevelId, onPlaced
|
||||
return (
|
||||
<CursorSphere
|
||||
color="#a78bfa"
|
||||
height={2.8}
|
||||
height={2.5}
|
||||
ref={cursorRef}
|
||||
showTooltip
|
||||
tooltipContent={COLUMN_ICON}
|
||||
|
||||
@@ -20,6 +20,9 @@ import {
|
||||
|
||||
export type FencePlanPoint = WallPlanPoint
|
||||
|
||||
const FENCE_CORNER_SNAP_RADIUS = 0.28
|
||||
const FENCE_SPAN_SNAP_RADIUS = 0.16
|
||||
|
||||
type SegmentNode = {
|
||||
start: FencePlanPoint
|
||||
end: FencePlanPoint
|
||||
@@ -57,46 +60,68 @@ function findFenceSnapTarget(
|
||||
fences: FenceNode[],
|
||||
ignoreFenceIds: string[] = [],
|
||||
): 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)
|
||||
let bestTarget: FencePlanPoint | null = null
|
||||
let bestDistanceSquared = Number.POSITIVE_INFINITY
|
||||
let bestCornerTarget: FencePlanPoint | null = null
|
||||
let bestCornerDistanceSquared = Number.POSITIVE_INFINITY
|
||||
let bestSpanTarget: FencePlanPoint | null = null
|
||||
let bestSpanDistanceSquared = Number.POSITIVE_INFINITY
|
||||
|
||||
for (const fence of fences) {
|
||||
if (ignoredFenceIds.has(fence.id)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const candidates: Array<FencePlanPoint | null> = [fence.start, fence.end]
|
||||
if (isCurvedWall(fence)) {
|
||||
const sampleCount = Math.max(8, Math.ceil(getWallCurveLength(fence) / 0.3))
|
||||
for (let index = 0; index <= sampleCount; index += 1) {
|
||||
const frame = getWallCurveFrameAt(fence, index / sampleCount)
|
||||
candidates.push([frame.point.x, frame.point.y])
|
||||
for (const candidate of [fence.start, fence.end]) {
|
||||
const candidateDistanceSquared = distanceSquared(point, candidate)
|
||||
if (
|
||||
candidateDistanceSquared > cornerRadiusSquared ||
|
||||
candidateDistanceSquared >= bestCornerDistanceSquared
|
||||
) {
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
candidates.push(projectPointOntoSegment(point, fence))
|
||||
|
||||
bestCornerTarget = candidate
|
||||
bestCornerDistanceSquared = candidateDistanceSquared
|
||||
}
|
||||
|
||||
for (const candidate of candidates) {
|
||||
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) {
|
||||
continue
|
||||
}
|
||||
|
||||
const candidateDistanceSquared = distanceSquared(point, candidate)
|
||||
if (
|
||||
candidateDistanceSquared > radiusSquared ||
|
||||
candidateDistanceSquared >= bestDistanceSquared
|
||||
candidateDistanceSquared > spanRadiusSquared ||
|
||||
candidateDistanceSquared >= bestSpanDistanceSquared
|
||||
) {
|
||||
continue
|
||||
}
|
||||
|
||||
bestTarget = candidate
|
||||
bestDistanceSquared = candidateDistanceSquared
|
||||
bestSpanTarget = candidate
|
||||
bestSpanDistanceSquared = candidateDistanceSquared
|
||||
}
|
||||
}
|
||||
|
||||
return bestTarget
|
||||
return bestCornerTarget ?? bestSpanTarget
|
||||
}
|
||||
|
||||
export function snapFenceDraftPoint(args: {
|
||||
|
||||
@@ -25,8 +25,13 @@ import {
|
||||
import { isWallLongEnough } from '../wall/wall-drafting'
|
||||
import { type FencePlanPoint, snapFenceDraftPoint } from './fence-drafting'
|
||||
|
||||
const LINKED_FENCE_ENDPOINT_EPSILON = 0.025
|
||||
|
||||
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 = {
|
||||
@@ -114,10 +119,9 @@ type LinkedFenceSnapshot = {
|
||||
function getLinkedFenceSnapshots(args: {
|
||||
fenceId: FenceNode['id']
|
||||
fenceParentId: string | null
|
||||
originalStart: FencePlanPoint
|
||||
originalEnd: FencePlanPoint
|
||||
linkedPoint: FencePlanPoint
|
||||
}) {
|
||||
const { fenceId, fenceParentId, originalStart, originalEnd } = args
|
||||
const { fenceId, fenceParentId, linkedPoint } = args
|
||||
const { nodes } = useScene.getState()
|
||||
const snapshots: LinkedFenceSnapshot[] = []
|
||||
|
||||
@@ -130,14 +134,7 @@ function getLinkedFenceSnapshots(args: {
|
||||
continue
|
||||
}
|
||||
|
||||
if (
|
||||
!(
|
||||
samePoint(node.start, originalStart) ||
|
||||
samePoint(node.start, originalEnd) ||
|
||||
samePoint(node.end, originalStart) ||
|
||||
samePoint(node.end, originalEnd)
|
||||
)
|
||||
) {
|
||||
if (!samePoint(node.start, linkedPoint) && !samePoint(node.end, linkedPoint)) {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -154,24 +151,14 @@ function getLinkedFenceSnapshots(args: {
|
||||
|
||||
function getLinkedFenceUpdates(
|
||||
linkedFences: LinkedFenceSnapshot[],
|
||||
originalStart: FencePlanPoint,
|
||||
originalEnd: FencePlanPoint,
|
||||
nextStart: FencePlanPoint,
|
||||
nextEnd: FencePlanPoint,
|
||||
linkedPoint: FencePlanPoint,
|
||||
nextLinkedPoint: FencePlanPoint,
|
||||
) {
|
||||
return linkedFences.map((fence) => ({
|
||||
id: fence.id,
|
||||
curveOffset: fence.curveOffset,
|
||||
start: samePoint(fence.start, originalStart)
|
||||
? nextStart
|
||||
: samePoint(fence.start, originalEnd)
|
||||
? nextEnd
|
||||
: fence.start,
|
||||
end: samePoint(fence.end, originalStart)
|
||||
? nextStart
|
||||
: samePoint(fence.end, originalEnd)
|
||||
? nextEnd
|
||||
: fence.end,
|
||||
start: samePoint(fence.start, linkedPoint) ? nextLinkedPoint : fence.start,
|
||||
end: samePoint(fence.end, linkedPoint) ? nextLinkedPoint : fence.end,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -183,6 +170,11 @@ export const MoveFenceEndpointTool: React.FC<{ target: MovingFenceEndpoint }> =
|
||||
const nodeIdRef = useRef(target.fence.id)
|
||||
const originalStartRef = useRef<FencePlanPoint>([...target.fence.start] 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>(
|
||||
target.endpoint === 'start'
|
||||
? ([...target.fence.end] as FencePlanPoint)
|
||||
@@ -192,8 +184,7 @@ export const MoveFenceEndpointTool: React.FC<{ target: MovingFenceEndpoint }> =
|
||||
getLinkedFenceSnapshots({
|
||||
fenceId: target.fence.id,
|
||||
fenceParentId: target.fence.parentId ?? null,
|
||||
originalStart: target.fence.start,
|
||||
originalEnd: target.fence.end,
|
||||
linkedPoint: target.endpoint === 'start' ? target.fence.start : target.fence.end,
|
||||
}),
|
||||
)
|
||||
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 originalStart = originalStartRef.current
|
||||
const originalEnd = originalEndRef.current
|
||||
const originalMovingPoint = originalMovingPointRef.current
|
||||
const fixedPoint = fixedPointRef.current
|
||||
const siblings = Object.values(useScene.getState().nodes)
|
||||
const levelWalls = siblings.filter(
|
||||
@@ -246,13 +238,7 @@ export const MoveFenceEndpointTool: React.FC<{ target: MovingFenceEndpoint }> =
|
||||
const nextEnd = target.endpoint === 'end' ? movingPoint : fixedPoint
|
||||
const linkedUpdates = detachLinkedFences
|
||||
? []
|
||||
: getLinkedFenceUpdates(
|
||||
linkedOriginalsRef.current,
|
||||
originalStart,
|
||||
originalEnd,
|
||||
nextStart,
|
||||
nextEnd,
|
||||
)
|
||||
: getLinkedFenceUpdates(linkedOriginalsRef.current, originalMovingPoint, movingPoint)
|
||||
previewRef.current = { start: nextStart, end: nextEnd }
|
||||
setCursorLocalPos([movingPoint[0], 0, movingPoint[1]])
|
||||
setAngleLabel(
|
||||
@@ -324,10 +310,8 @@ export const MoveFenceEndpointTool: React.FC<{ target: MovingFenceEndpoint }> =
|
||||
? []
|
||||
: getLinkedFenceUpdates(
|
||||
linkedOriginalsRef.current,
|
||||
originalStart,
|
||||
originalEnd,
|
||||
preview.start,
|
||||
preview.end,
|
||||
originalMovingPoint,
|
||||
target.endpoint === 'start' ? preview.start : preview.end,
|
||||
)),
|
||||
])
|
||||
pauseSceneHistory(useScene)
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import '../../../three-types'
|
||||
|
||||
import { Icon } from '@iconify/react'
|
||||
import {
|
||||
type AnyNodeId,
|
||||
type CeilingNode,
|
||||
type ColumnNode,
|
||||
emitter,
|
||||
type GridEvent,
|
||||
type ItemNode,
|
||||
@@ -13,6 +16,7 @@ import {
|
||||
type ZoneNode,
|
||||
} from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import type { ThreeElements } from '@react-three/fiber'
|
||||
import { useThree } from '@react-three/fiber'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import {
|
||||
@@ -34,6 +38,12 @@ import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
import useEditor from '../../../store/use-editor'
|
||||
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
|
||||
* 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)) {
|
||||
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') {
|
||||
const item = node as ItemNode
|
||||
if (item.asset.category === 'door' || item.asset.category === 'window') continue
|
||||
|
||||
@@ -2,20 +2,35 @@
|
||||
|
||||
import {
|
||||
type AnyNodeId,
|
||||
constrainWallMoveDeltaToAxis,
|
||||
DEFAULT_WALL_HEIGHT,
|
||||
detectSpacesForLevel,
|
||||
emitter,
|
||||
type GridEvent,
|
||||
getMaterialPresetByRef,
|
||||
getPerpendicularWallMoveAxis,
|
||||
pauseSceneHistory,
|
||||
planAutoSlabsForLevel,
|
||||
planWallMoveJunctions,
|
||||
resolveMaterial,
|
||||
resumeSceneHistory,
|
||||
type SlabNode,
|
||||
useScene,
|
||||
type WallMoveAxis,
|
||||
type WallMoveBridgePlan,
|
||||
type WallMoveJunctionPlan,
|
||||
type WallNode,
|
||||
WallNode as WallSchema,
|
||||
} from '@pascal-app/core'
|
||||
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 { EDITOR_LAYER } from '../../../lib/constants'
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
import useEditor from '../../../store/use-editor'
|
||||
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] {
|
||||
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]
|
||||
}
|
||||
|
||||
function pointKey(point: [number, number]) {
|
||||
return `${point[0]}:${point[1]}`
|
||||
}
|
||||
|
||||
function stripWallIsNewMetadata(meta: WallNode['metadata']): WallNode['metadata'] {
|
||||
if (!meta || typeof meta !== 'object' || Array.isArray(meta)) {
|
||||
return meta
|
||||
@@ -37,10 +56,14 @@ function stripWallIsNewMetadata(meta: WallNode['metadata']): WallNode['metadata'
|
||||
return nextMeta as WallNode['metadata']
|
||||
}
|
||||
|
||||
type LinkedWallSnapshot = {
|
||||
id: WallNode['id']
|
||||
type LinkedWallSnapshot = WallNode
|
||||
|
||||
type GhostWallPreview = {
|
||||
id: string
|
||||
start: [number, number]
|
||||
end: [number, number]
|
||||
color: string
|
||||
height: number
|
||||
}
|
||||
|
||||
function getLinkedWallSnapshots(args: {
|
||||
@@ -51,32 +74,42 @@ function getLinkedWallSnapshots(args: {
|
||||
}) {
|
||||
const { wallId, wallParentId, originalStart, originalEnd } = args
|
||||
const { nodes } = useScene.getState()
|
||||
const walls = Object.values(nodes).filter(
|
||||
(node): node is WallNode =>
|
||||
node?.type === 'wall' && node.id !== wallId && (node.parentId ?? null) === wallParentId,
|
||||
)
|
||||
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 Object.values(nodes)) {
|
||||
if (!(node?.type === 'wall' && node.id !== wallId)) {
|
||||
for (const node of walls) {
|
||||
if (!contextPoints.has(pointKey(node.start)) && !contextPoints.has(pointKey(node.end))) {
|
||||
continue
|
||||
}
|
||||
|
||||
if ((node.parentId ?? null) !== wallParentId) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (
|
||||
!(
|
||||
samePoint(node.start, originalStart) ||
|
||||
samePoint(node.start, originalEnd) ||
|
||||
samePoint(node.end, originalStart) ||
|
||||
samePoint(node.end, originalEnd)
|
||||
)
|
||||
) {
|
||||
if (seenWallIds.has(node.id)) {
|
||||
continue
|
||||
}
|
||||
seenWallIds.add(node.id)
|
||||
|
||||
snapshots.push({
|
||||
id: node.id,
|
||||
...node,
|
||||
start: [...node.start] as [number, number],
|
||||
end: [...node.end] as [number, number],
|
||||
children: [...(node.children ?? [])],
|
||||
})
|
||||
}
|
||||
|
||||
@@ -84,25 +117,283 @@ function getLinkedWallSnapshots(args: {
|
||||
}
|
||||
|
||||
function getLinkedWallUpdates(
|
||||
linkedWalls: LinkedWallSnapshot[],
|
||||
linkedWalls: Array<{
|
||||
wall: LinkedWallSnapshot
|
||||
matchPoint?: [number, number]
|
||||
targetPoint?: [number, number]
|
||||
}>,
|
||||
originalStart: [number, number],
|
||||
originalEnd: [number, number],
|
||||
nextStart: [number, number],
|
||||
nextEnd: [number, number],
|
||||
) {
|
||||
return linkedWalls.map((wall) => ({
|
||||
id: wall.id,
|
||||
start: samePoint(wall.start, originalStart)
|
||||
? nextStart
|
||||
: samePoint(wall.start, originalEnd)
|
||||
? nextEnd
|
||||
: wall.start,
|
||||
end: samePoint(wall.end, originalStart)
|
||||
? nextStart
|
||||
: samePoint(wall.end, originalEnd)
|
||||
? nextEnd
|
||||
: wall.end,
|
||||
}))
|
||||
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,
|
||||
start: samePoint(wall.start, originalStart)
|
||||
? targetStart
|
||||
: samePoint(wall.start, originalEnd)
|
||||
? targetEnd
|
||||
: wall.start,
|
||||
end: samePoint(wall.end, originalStart)
|
||||
? targetStart
|
||||
: samePoint(wall.end, originalEnd)
|
||||
? targetEnd
|
||||
: 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 }) => {
|
||||
@@ -123,7 +414,10 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
|
||||
(node.end[0] - node.start[0]) / 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
|
||||
? []
|
||||
: getLinkedWallSnapshots({
|
||||
@@ -133,6 +427,9 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
|
||||
originalEnd: node.end,
|
||||
}),
|
||||
)
|
||||
const originalAutoSlabsRef = useRef<SlabNode[]>(
|
||||
node.parentId ? getLevelAutoSlabSnapshots(node.parentId) : [],
|
||||
)
|
||||
const dragAnchorRef = useRef<[number, number] | null>(null)
|
||||
const nodeIdRef = useRef(node.id)
|
||||
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
|
||||
return [centerX, 0, centerZ]
|
||||
})
|
||||
const [ghostWallPreviews, setGhostWallPreviews] = useState<GhostWallPreview[]>([])
|
||||
|
||||
const exitMoveMode = useCallback(() => {
|
||||
useEditor.getState().setMovingNode(null)
|
||||
@@ -155,9 +453,11 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
|
||||
const originalEnd = originalEndRef.current
|
||||
const originalCenter = originalCenterRef.current
|
||||
const originalHalfVector = originalHalfVectorRef.current
|
||||
const levelId = node.parentId ?? null
|
||||
const originalAutoSlabs = originalAutoSlabsRef.current
|
||||
|
||||
pauseSceneHistory(useScene)
|
||||
let wasCommitted = false
|
||||
let shouldRestoreOnCleanup = true
|
||||
|
||||
const applyNodePreview = (
|
||||
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 rotatedHalf = rotateVector(originalHalfVector, pendingRotationRef.current)
|
||||
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 }
|
||||
}
|
||||
|
||||
const getMovePlan = (nextStart: [number, number], nextEnd: [number, number]) =>
|
||||
planWallMoveJunctions(
|
||||
linkedOriginalsRef.current,
|
||||
originalStart,
|
||||
originalEnd,
|
||||
nextStart,
|
||||
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])
|
||||
applyNodePreview([
|
||||
const previewPlan = getMovePlan(nextStart, nextEnd)
|
||||
const previewUpdates = [
|
||||
{ id: nodeId, start: nextStart, end: nextEnd },
|
||||
...getLinkedWallUpdates(
|
||||
linkedOriginalsRef.current,
|
||||
originalStart,
|
||||
originalEnd,
|
||||
nextStart,
|
||||
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 = () => {
|
||||
setGhostWallPreviews([])
|
||||
applyNodePreview([
|
||||
{ id: nodeId, start: originalStart, end: originalEnd },
|
||||
...linkedOriginalsRef.current,
|
||||
])
|
||||
restoreAutoSlabPreview()
|
||||
}
|
||||
|
||||
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 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]
|
||||
dragAnchorRef.current = anchor
|
||||
|
||||
const deltaX = localX - anchor[0]
|
||||
const deltaZ = localZ - anchor[1]
|
||||
const [deltaX, deltaZ] = constrainWallMoveDeltaToAxis(
|
||||
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 nextWall = buildWallFromCenter(nextCenter)
|
||||
@@ -238,16 +658,32 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
|
||||
|
||||
const preview = previewRef.current ?? { start: originalStart, end: originalEnd }
|
||||
|
||||
wasCommitted = true
|
||||
shouldRestoreOnCleanup = false
|
||||
|
||||
// Restore original baseline while paused so the next resume+update
|
||||
// registers as a single tracked change (undo reverts to original).
|
||||
setGhostWallPreviews([])
|
||||
applyNodePreview([
|
||||
{ id: nodeId, start: originalStart, end: originalEnd },
|
||||
...linkedOriginalsRef.current,
|
||||
])
|
||||
restoreAutoSlabPreview()
|
||||
|
||||
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 = [
|
||||
{
|
||||
@@ -260,21 +696,29 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
|
||||
}
|
||||
: { start: preview.start, end: preview.end },
|
||||
},
|
||||
...getLinkedWallUpdates(
|
||||
linkedOriginalsRef.current,
|
||||
originalStart,
|
||||
originalEnd,
|
||||
preview.start,
|
||||
preview.end,
|
||||
).map((entry) => ({
|
||||
id: entry.id as AnyNodeId,
|
||||
data: { start: entry.start, end: entry.end },
|
||||
})),
|
||||
...linkedWallUpdates
|
||||
.filter((entry) => !collapsedLinkedWallIds.has(entry.id as AnyNodeId))
|
||||
.map((entry) => ({
|
||||
id: entry.id as AnyNodeId,
|
||||
data: { start: entry.start, end: entry.end },
|
||||
})),
|
||||
]
|
||||
useScene.getState().updateNodes(commitUpdates)
|
||||
for (const { id } of commitUpdates) {
|
||||
useScene.getState().markDirty(id)
|
||||
}
|
||||
const sceneState = useScene.getState()
|
||||
const existingWalls = getWallsAfterUpdates(sceneState.nodes, commitUpdates).filter(
|
||||
(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)
|
||||
|
||||
@@ -313,6 +757,7 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
|
||||
(preview.start[1] + preview.end[1]) / 2,
|
||||
]
|
||||
const nextWall = buildWallFromCenter(currentCenter)
|
||||
moveAxisRef.current = getPerpendicularWallMoveAxis(nextWall.start, nextWall.end)
|
||||
applyPreview(nextWall.start, nextWall.end)
|
||||
}
|
||||
|
||||
@@ -323,6 +768,7 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
|
||||
}
|
||||
|
||||
const onCancel = () => {
|
||||
shouldRestoreOnCleanup = false
|
||||
restoreOriginal()
|
||||
useViewer.getState().setSelection({ selectedIds: [nodeId] })
|
||||
resumeSceneHistory(useScene)
|
||||
@@ -337,7 +783,7 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
|
||||
window.addEventListener('keyup', onKeyUp)
|
||||
|
||||
return () => {
|
||||
if (!wasCommitted) {
|
||||
if (shouldRestoreOnCleanup) {
|
||||
restoreOriginal()
|
||||
}
|
||||
shiftPressedRef.current = false
|
||||
@@ -348,11 +794,14 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
|
||||
window.removeEventListener('keydown', onKeyDown)
|
||||
window.removeEventListener('keyup', onKeyUp)
|
||||
}
|
||||
}, [exitMoveMode, isNew, node.metadata])
|
||||
}, [exitMoveMode, isNew, node.metadata, node.parentId])
|
||||
|
||||
return (
|
||||
<group>
|
||||
<CursorSphere position={cursorLocalPos} showTooltip={false} />
|
||||
{ghostWallPreviews.map((preview) => (
|
||||
<GhostWallPreviewMesh key={preview.id} preview={preview} />
|
||||
))}
|
||||
</group>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
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 {
|
||||
type ButtonHTMLAttributes,
|
||||
type CSSProperties,
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
useSyncExternalStore,
|
||||
} from 'react'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import {
|
||||
@@ -41,6 +42,12 @@ import {
|
||||
type LevelDuplicatePreset,
|
||||
} from '../../lib/level-duplication'
|
||||
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 { LevelDuplicateDialog } from './level-duplicate-dialog'
|
||||
import {
|
||||
@@ -126,6 +133,7 @@ function LevelRow({
|
||||
dragHandleRef,
|
||||
onSelect,
|
||||
onDuplicate,
|
||||
onPaste,
|
||||
onRequestDelete,
|
||||
}: {
|
||||
level: LevelNode
|
||||
@@ -135,6 +143,7 @@ function LevelRow({
|
||||
dragHandleRef?: (element: HTMLButtonElement | null) => void
|
||||
onSelect: () => void
|
||||
onDuplicate: (preset?: LevelDuplicatePreset) => void
|
||||
onPaste?: () => void
|
||||
onRequestDelete: () => void
|
||||
}) {
|
||||
const [duplicateDialogOpen, setDuplicateDialogOpen] = useState(false)
|
||||
@@ -223,6 +232,19 @@ function LevelRow({
|
||||
<Copy className="h-3 w-3" />
|
||||
Duplicate with options...
|
||||
</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
|
||||
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) => {
|
||||
@@ -256,12 +278,14 @@ function SortableLevelRow({
|
||||
isSelected,
|
||||
onSelect,
|
||||
onDuplicate,
|
||||
onPaste,
|
||||
onRequestDelete,
|
||||
}: {
|
||||
level: LevelNode
|
||||
isSelected: boolean
|
||||
onSelect: () => void
|
||||
onDuplicate: (preset?: LevelDuplicatePreset) => void
|
||||
onPaste?: () => void
|
||||
onRequestDelete: () => void
|
||||
}) {
|
||||
const {
|
||||
@@ -291,6 +315,7 @@ function SortableLevelRow({
|
||||
isSelected={isSelected}
|
||||
level={level}
|
||||
onDuplicate={onDuplicate}
|
||||
onPaste={onPaste}
|
||||
onRequestDelete={onRequestDelete}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
@@ -310,6 +335,11 @@ export function FloatingLevelSelector() {
|
||||
|
||||
const [deletingLevel, setDeletingLevel] = useState<LevelNode | null>(null)
|
||||
const [draggingLevelId, setDraggingLevelId] = useState<string | null>(null)
|
||||
const clipboardSnapshot = useSyncExternalStore(
|
||||
subscribeEditorClipboard,
|
||||
getEditorClipboardSnapshot,
|
||||
getEditorClipboardSnapshot,
|
||||
)
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: { distance: 4 },
|
||||
@@ -424,6 +454,13 @@ export function FloatingLevelSelector() {
|
||||
[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) => {
|
||||
setDraggingLevelId(String(event.active.id))
|
||||
}, [])
|
||||
@@ -523,6 +560,9 @@ export function FloatingLevelSelector() {
|
||||
isSelected={isSelected}
|
||||
level={level}
|
||||
onDuplicate={(preset) => handleDuplicateLevel(level, preset)}
|
||||
onPaste={
|
||||
clipboardSnapshot ? () => handlePasteToLevel(level) : undefined
|
||||
}
|
||||
onRequestDelete={() => setDeletingLevel(level)}
|
||||
onSelect={() =>
|
||||
setSelection(
|
||||
|
||||
@@ -11,10 +11,12 @@ import { useViewer } from '@pascal-app/viewer'
|
||||
import { Move, Trash2 } from 'lucide-react'
|
||||
import { useCallback } from 'react'
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
import { cn } from '../../../lib/utils'
|
||||
import useEditor from '../../../store/use-editor'
|
||||
import { ActionButton, ActionGroup } from '../controls/action-button'
|
||||
import { PanelSection } from '../controls/panel-section'
|
||||
import { SliderControl } from '../controls/slider-control'
|
||||
import { ToggleControl } from '../controls/toggle-control'
|
||||
import { PanelWrapper } from './panel-wrapper'
|
||||
|
||||
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) {
|
||||
return Math.min(max, Math.max(min, value))
|
||||
}
|
||||
@@ -85,6 +101,7 @@ function presetUpdates(presetId: ColumnPresetId): Partial<ColumnNode> {
|
||||
const { label, ...preset } = COLUMN_PRESETS[presetId]
|
||||
return {
|
||||
name: label,
|
||||
supportStyle: 'supportStyle' in preset ? preset.supportStyle : 'vertical',
|
||||
...preset,
|
||||
}
|
||||
}
|
||||
@@ -201,6 +218,18 @@ export function ColumnPanel() {
|
||||
|
||||
if (!(node && node.type === 'column' && selectedId && selectedCount === 1)) return null
|
||||
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 (
|
||||
<PanelWrapper
|
||||
@@ -228,57 +257,119 @@ export function ColumnPanel() {
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Shape">
|
||||
<select
|
||||
className={SELECT_CLASS}
|
||||
onChange={(event) =>
|
||||
handleUpdate({ crossSection: event.target.value as ColumnNode['crossSection'] })
|
||||
}
|
||||
value={node.crossSection}
|
||||
>
|
||||
<option value="round">Round</option>
|
||||
<option value="square">Square</option>
|
||||
<option value="rectangular">Rectangular</option>
|
||||
</select>
|
||||
<SliderControl
|
||||
label="Edge Softness"
|
||||
max={0.12}
|
||||
min={0}
|
||||
onChange={(value) => handleUpdate({ edgeSoftness: value })}
|
||||
precision={3}
|
||||
step={0.005}
|
||||
unit="m"
|
||||
value={node.edgeSoftness ?? 0.025}
|
||||
/>
|
||||
{(node.crossSection === 'square' || node.crossSection === 'rectangular') && (
|
||||
<SliderControl
|
||||
label="Shaft Corner Radius"
|
||||
max={0.3}
|
||||
min={0}
|
||||
onChange={(value) => handleUpdate({ shaftCornerRadius: value })}
|
||||
precision={3}
|
||||
step={0.005}
|
||||
unit="m"
|
||||
value={node.shaftCornerRadius ?? 0.035}
|
||||
/>
|
||||
<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
|
||||
className={SELECT_CLASS}
|
||||
onChange={(event) =>
|
||||
handleUpdate({ crossSection: event.target.value as ColumnNode['crossSection'] })
|
||||
}
|
||||
value={node.crossSection}
|
||||
>
|
||||
<option value="round">Round</option>
|
||||
<option value="square">Square</option>
|
||||
<option value="rectangular">Rectangular</option>
|
||||
</select>
|
||||
<SliderControl
|
||||
label="Edge Softness"
|
||||
max={0.12}
|
||||
min={0}
|
||||
onChange={(value) => handleUpdate({ edgeSoftness: value })}
|
||||
precision={3}
|
||||
step={0.005}
|
||||
unit="m"
|
||||
value={node.edgeSoftness ?? 0.025}
|
||||
/>
|
||||
{(node.crossSection === 'square' || node.crossSection === 'rectangular') && (
|
||||
<SliderControl
|
||||
label="Shaft Corner Radius"
|
||||
max={0.3}
|
||||
min={0}
|
||||
onChange={(value) => handleUpdate({ shaftCornerRadius: value })}
|
||||
precision={3}
|
||||
step={0.005}
|
||||
unit="m"
|
||||
value={node.shaftCornerRadius ?? 0.035}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Dimensions">
|
||||
<select
|
||||
className={SELECT_CLASS}
|
||||
onChange={(event) => {
|
||||
if (!event.target.value) return
|
||||
handleUpdate(proportionUpdates(node, event.target.value as ColumnProportionPresetId))
|
||||
}}
|
||||
value=""
|
||||
>
|
||||
<option value="">Apply proportion...</option>
|
||||
{COLUMN_PROPORTION_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{!isBraceSupport && (
|
||||
<select
|
||||
className={SELECT_CLASS}
|
||||
onChange={(event) => {
|
||||
if (!event.target.value) return
|
||||
handleUpdate(proportionUpdates(node, event.target.value as ColumnProportionPresetId))
|
||||
}}
|
||||
value=""
|
||||
>
|
||||
<option value="">Apply proportion...</option>
|
||||
{COLUMN_PROPORTION_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<SliderControl
|
||||
label="Height"
|
||||
max={6}
|
||||
@@ -289,205 +380,287 @@ export function ColumnPanel() {
|
||||
unit="m"
|
||||
value={node.height}
|
||||
/>
|
||||
<SliderControl
|
||||
label="Width"
|
||||
max={1.6}
|
||||
min={0.12}
|
||||
onChange={(value) =>
|
||||
handleUpdate({
|
||||
width: value,
|
||||
radius: value / 2,
|
||||
...(node.crossSection === 'rectangular' ? {} : { depth: value }),
|
||||
})
|
||||
}
|
||||
precision={2}
|
||||
step={0.02}
|
||||
unit="m"
|
||||
value={node.width}
|
||||
/>
|
||||
{node.crossSection === 'rectangular' && (
|
||||
<SliderControl
|
||||
label="Depth"
|
||||
max={1.6}
|
||||
min={0.12}
|
||||
onChange={(value) => handleUpdate({ depth: value })}
|
||||
precision={2}
|
||||
step={0.02}
|
||||
unit="m"
|
||||
value={node.depth}
|
||||
/>
|
||||
{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
|
||||
label="Width"
|
||||
max={1.6}
|
||||
min={0.12}
|
||||
onChange={(value) =>
|
||||
handleUpdate({
|
||||
width: value,
|
||||
radius: value / 2,
|
||||
...(node.crossSection === 'rectangular' ? {} : { depth: value }),
|
||||
})
|
||||
}
|
||||
precision={2}
|
||||
step={0.02}
|
||||
unit="m"
|
||||
value={node.width}
|
||||
/>
|
||||
{node.crossSection === 'rectangular' && (
|
||||
<SliderControl
|
||||
label="Depth"
|
||||
max={1.6}
|
||||
min={0.12}
|
||||
onChange={(value) => handleUpdate({ depth: value })}
|
||||
precision={2}
|
||||
step={0.02}
|
||||
unit="m"
|
||||
value={node.depth}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Shaft">
|
||||
<select
|
||||
className={SELECT_CLASS}
|
||||
onChange={(event) =>
|
||||
handleUpdate(shaftProfileUpdates(event.target.value as ColumnNode['shaftProfile']))
|
||||
}
|
||||
value={shaftProfile}
|
||||
>
|
||||
<option value="straight">Straight</option>
|
||||
<option value="tapered">Tapered</option>
|
||||
<option value="bulged">Bulged</option>
|
||||
<option value="hourglass">Hourglass</option>
|
||||
</select>
|
||||
{shaftProfile === 'straight' && (
|
||||
{!isBraceSupport && (
|
||||
<PanelSection title="Shaft">
|
||||
<select
|
||||
className={SELECT_CLASS}
|
||||
onChange={(event) =>
|
||||
handleUpdate(
|
||||
shaftProfileUpdates(event.target.value as ColumnNode['shaftProfile']),
|
||||
)
|
||||
}
|
||||
value={shaftProfile}
|
||||
>
|
||||
<option value="straight">Straight</option>
|
||||
<option value="tapered">Tapered</option>
|
||||
<option value="bulged">Bulged</option>
|
||||
<option value="hourglass">Hourglass</option>
|
||||
</select>
|
||||
{shaftProfile === 'straight' && (
|
||||
<SliderControl
|
||||
label="Shaft Width"
|
||||
max={1.2}
|
||||
min={0.3}
|
||||
onChange={(value) => handleUpdate({ shaftStartScale: value, shaftEndScale: value })}
|
||||
precision={2}
|
||||
step={0.02}
|
||||
value={node.shaftStartScale ?? 0.72}
|
||||
/>
|
||||
)}
|
||||
{shaftProfile === 'tapered' && (
|
||||
<>
|
||||
<SliderControl
|
||||
label="Bottom Width"
|
||||
max={1.2}
|
||||
min={0.3}
|
||||
onChange={(value) => handleUpdate({ shaftStartScale: value })}
|
||||
precision={2}
|
||||
step={0.02}
|
||||
value={node.shaftStartScale ?? 0.82}
|
||||
/>
|
||||
<SliderControl
|
||||
label="Top Width"
|
||||
max={1.2}
|
||||
min={0.3}
|
||||
onChange={(value) => handleUpdate({ shaftEndScale: value })}
|
||||
precision={2}
|
||||
step={0.02}
|
||||
value={node.shaftEndScale ?? 0.72}
|
||||
/>
|
||||
<SliderControl
|
||||
label="Taper"
|
||||
max={0.45}
|
||||
min={0}
|
||||
onChange={(value) => handleUpdate({ shaftTaper: value })}
|
||||
precision={2}
|
||||
step={0.01}
|
||||
value={node.shaftTaper ?? 0.14}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{shaftProfile === 'bulged' && (
|
||||
<>
|
||||
<SliderControl
|
||||
label="End Width"
|
||||
max={1.2}
|
||||
min={0.3}
|
||||
onChange={(value) =>
|
||||
handleUpdate({ shaftStartScale: value, shaftEndScale: value })
|
||||
}
|
||||
precision={2}
|
||||
step={0.02}
|
||||
value={node.shaftStartScale ?? 0.68}
|
||||
/>
|
||||
<SliderControl
|
||||
label="Bulge"
|
||||
max={0.35}
|
||||
min={0}
|
||||
onChange={(value) => handleUpdate({ shaftBulge: value })}
|
||||
precision={2}
|
||||
step={0.01}
|
||||
value={node.shaftBulge ?? 0.12}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{shaftProfile === 'hourglass' && (
|
||||
<>
|
||||
<SliderControl
|
||||
label="End Width"
|
||||
max={1.2}
|
||||
min={0.3}
|
||||
onChange={(value) =>
|
||||
handleUpdate({ shaftStartScale: value, shaftEndScale: value })
|
||||
}
|
||||
precision={2}
|
||||
step={0.02}
|
||||
value={node.shaftStartScale ?? 0.84}
|
||||
/>
|
||||
<SliderControl
|
||||
label="Waist"
|
||||
max={0.35}
|
||||
min={0}
|
||||
onChange={(value) => handleUpdate({ shaftBulge: value })}
|
||||
precision={2}
|
||||
step={0.01}
|
||||
value={node.shaftBulge ?? 0.12}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<SliderControl
|
||||
label="Shaft Width"
|
||||
max={1.2}
|
||||
min={0.3}
|
||||
onChange={(value) => handleUpdate({ shaftStartScale: value, shaftEndScale: value })}
|
||||
precision={2}
|
||||
step={0.02}
|
||||
value={node.shaftStartScale ?? 0.72}
|
||||
label="Segment Twist"
|
||||
max={90}
|
||||
min={-90}
|
||||
onChange={(value) =>
|
||||
handleUpdate({
|
||||
shaftTwistStep: value,
|
||||
...(Math.abs(value) > 0.001 && (node.shaftSegmentCount ?? 1) < 8
|
||||
? { shaftSegmentCount: 12 }
|
||||
: {}),
|
||||
})
|
||||
}
|
||||
precision={0}
|
||||
step={5}
|
||||
unit="°"
|
||||
value={node.shaftTwistStep ?? 0}
|
||||
/>
|
||||
)}
|
||||
{shaftProfile === 'tapered' && (
|
||||
<>
|
||||
{Math.abs(node.shaftTwistStep ?? 0) > 0.001 && (
|
||||
<SliderControl
|
||||
label="Bottom Width"
|
||||
max={1.2}
|
||||
min={0.3}
|
||||
onChange={(value) => handleUpdate({ shaftStartScale: value })}
|
||||
precision={2}
|
||||
step={0.02}
|
||||
value={node.shaftStartScale ?? 0.82}
|
||||
label="Twist Segments"
|
||||
max={48}
|
||||
min={4}
|
||||
onChange={(value) => handleUpdate({ shaftSegmentCount: Math.round(value) })}
|
||||
precision={0}
|
||||
step={1}
|
||||
value={node.shaftSegmentCount ?? 12}
|
||||
/>
|
||||
<SliderControl
|
||||
label="Top Width"
|
||||
max={1.2}
|
||||
min={0.3}
|
||||
onChange={(value) => handleUpdate({ shaftEndScale: value })}
|
||||
precision={2}
|
||||
step={0.02}
|
||||
value={node.shaftEndScale ?? 0.72}
|
||||
/>
|
||||
<SliderControl
|
||||
label="Taper"
|
||||
max={0.45}
|
||||
min={0}
|
||||
onChange={(value) => handleUpdate({ shaftTaper: value })}
|
||||
precision={2}
|
||||
step={0.01}
|
||||
value={node.shaftTaper ?? 0.14}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{shaftProfile === 'bulged' && (
|
||||
<>
|
||||
<SliderControl
|
||||
label="End Width"
|
||||
max={1.2}
|
||||
min={0.3}
|
||||
onChange={(value) => handleUpdate({ shaftStartScale: value, shaftEndScale: value })}
|
||||
precision={2}
|
||||
step={0.02}
|
||||
value={node.shaftStartScale ?? 0.68}
|
||||
/>
|
||||
<SliderControl
|
||||
label="Bulge"
|
||||
max={0.35}
|
||||
min={0}
|
||||
onChange={(value) => handleUpdate({ shaftBulge: value })}
|
||||
precision={2}
|
||||
step={0.01}
|
||||
value={node.shaftBulge ?? 0.12}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{shaftProfile === 'hourglass' && (
|
||||
<>
|
||||
<SliderControl
|
||||
label="End Width"
|
||||
max={1.2}
|
||||
min={0.3}
|
||||
onChange={(value) => handleUpdate({ shaftStartScale: value, shaftEndScale: value })}
|
||||
precision={2}
|
||||
step={0.02}
|
||||
value={node.shaftStartScale ?? 0.84}
|
||||
/>
|
||||
<SliderControl
|
||||
label="Waist"
|
||||
max={0.35}
|
||||
min={0}
|
||||
onChange={(value) => handleUpdate({ shaftBulge: value })}
|
||||
precision={2}
|
||||
step={0.01}
|
||||
value={node.shaftBulge ?? 0.12}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<SliderControl
|
||||
label="Segment Twist"
|
||||
max={90}
|
||||
min={-90}
|
||||
onChange={(value) =>
|
||||
handleUpdate({
|
||||
shaftTwistStep: value,
|
||||
...(Math.abs(value) > 0.001 && (node.shaftSegmentCount ?? 1) < 8
|
||||
? { shaftSegmentCount: 12 }
|
||||
: {}),
|
||||
})
|
||||
}
|
||||
precision={0}
|
||||
step={5}
|
||||
unit="°"
|
||||
value={node.shaftTwistStep ?? 0}
|
||||
/>
|
||||
{Math.abs(node.shaftTwistStep ?? 0) > 0.001 && (
|
||||
)}
|
||||
<SliderControl
|
||||
label="Twist Segments"
|
||||
max={48}
|
||||
min={4}
|
||||
onChange={(value) => handleUpdate({ shaftSegmentCount: Math.round(value) })}
|
||||
label="Ring Pairs"
|
||||
max={4}
|
||||
min={0}
|
||||
onChange={(value) =>
|
||||
handleUpdate({
|
||||
ringCount: Math.round(value) * 2,
|
||||
ringPlacement: 'ends',
|
||||
ringSpread: node.ringSpread ?? 0.16,
|
||||
ringThickness: node.ringThickness ?? 0.055,
|
||||
})
|
||||
}
|
||||
precision={0}
|
||||
step={1}
|
||||
value={node.shaftSegmentCount ?? 12}
|
||||
value={Math.ceil((node.ringCount ?? 0) / 2)}
|
||||
/>
|
||||
)}
|
||||
<SliderControl
|
||||
label="Ring Pairs"
|
||||
max={4}
|
||||
min={0}
|
||||
onChange={(value) =>
|
||||
handleUpdate({
|
||||
ringCount: Math.round(value) * 2,
|
||||
ringPlacement: 'ends',
|
||||
ringSpread: node.ringSpread ?? 0.16,
|
||||
ringThickness: node.ringThickness ?? 0.055,
|
||||
})
|
||||
}
|
||||
precision={0}
|
||||
step={1}
|
||||
value={Math.ceil((node.ringCount ?? 0) / 2)}
|
||||
/>
|
||||
{(node.ringCount ?? 0) > 0 && (
|
||||
<SliderControl
|
||||
label="Ring Thickness"
|
||||
max={0.14}
|
||||
min={0.01}
|
||||
onChange={(value) => handleUpdate({ ringThickness: value })}
|
||||
precision={3}
|
||||
step={0.005}
|
||||
unit="m"
|
||||
value={node.ringThickness ?? 0.055}
|
||||
/>
|
||||
)}
|
||||
{(node.ringCount ?? 0) > 0 && (
|
||||
<SliderControl
|
||||
label="Ring Spread"
|
||||
max={0.45}
|
||||
min={0.04}
|
||||
onChange={(value) => handleUpdate({ ringSpread: value, ringPlacement: 'ends' })}
|
||||
precision={2}
|
||||
step={0.01}
|
||||
value={node.ringSpread ?? 0.16}
|
||||
/>
|
||||
)}
|
||||
</PanelSection>
|
||||
{(node.ringCount ?? 0) > 0 && (
|
||||
<SliderControl
|
||||
label="Ring Thickness"
|
||||
max={0.14}
|
||||
min={0.01}
|
||||
onChange={(value) => handleUpdate({ ringThickness: value })}
|
||||
precision={3}
|
||||
step={0.005}
|
||||
unit="m"
|
||||
value={node.ringThickness ?? 0.055}
|
||||
/>
|
||||
)}
|
||||
{(node.ringCount ?? 0) > 0 && (
|
||||
<SliderControl
|
||||
label="Ring Spread"
|
||||
max={0.45}
|
||||
min={0.04}
|
||||
onChange={(value) => handleUpdate({ ringSpread: value, ringPlacement: 'ends' })}
|
||||
precision={2}
|
||||
step={0.01}
|
||||
value={node.ringSpread ?? 0.16}
|
||||
/>
|
||||
)}
|
||||
</PanelSection>
|
||||
)}
|
||||
|
||||
<PanelSection title="Ends">
|
||||
{!isBraceSupport && (
|
||||
<PanelSection title="Ends">
|
||||
<select
|
||||
className={SELECT_CLASS}
|
||||
onChange={(event) => {
|
||||
@@ -728,7 +901,8 @@ export function ColumnPanel() {
|
||||
value={node.baseStepSpread ?? 0.34}
|
||||
/>
|
||||
)}
|
||||
</PanelSection>
|
||||
</PanelSection>
|
||||
)}
|
||||
|
||||
<PanelSection title="Transform">
|
||||
<SliderControl
|
||||
|
||||
@@ -127,8 +127,11 @@ export function DoorPanel() {
|
||||
const handleUpdate = useCallback(
|
||||
(updates: Partial<DoorNode>) => {
|
||||
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 currentValue = node[key as keyof DoorNode]
|
||||
const currentValue = liveNode[key as keyof DoorNode]
|
||||
return !isSameDoorValue(currentValue, value)
|
||||
})
|
||||
if (!hasChange) return
|
||||
@@ -137,7 +140,9 @@ export function DoorPanel() {
|
||||
useInteractive.getState().removeDoorOpenState(selectedId as AnyNodeId)
|
||||
}
|
||||
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],
|
||||
)
|
||||
@@ -355,7 +360,9 @@ export function DoorPanel() {
|
||||
const isRollupGarageDoor = doorType === 'garage-rollup'
|
||||
const isTiltupGarageDoor = doorType === 'garage-tiltup'
|
||||
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 setOpeningTopRadius = (index: number, value: number, commit = false) => {
|
||||
@@ -402,6 +409,7 @@ export function DoorPanel() {
|
||||
handleSide: 'right',
|
||||
trackStyle: 'visible',
|
||||
operationState: Math.max(node.operationState ?? 0, 0.65),
|
||||
threshold: false,
|
||||
contentPadding: [0.03, 0.04],
|
||||
segments: foldingDoorSegments,
|
||||
}
|
||||
@@ -418,6 +426,7 @@ export function DoorPanel() {
|
||||
trackStyle: 'pocket',
|
||||
slideDirection: node.slideDirection ?? 'left',
|
||||
operationState: node.operationState ?? 0,
|
||||
threshold: false,
|
||||
contentPadding: [0.035, 0.045],
|
||||
segments: foldingDoorSegments,
|
||||
}
|
||||
@@ -434,6 +443,7 @@ export function DoorPanel() {
|
||||
trackStyle: 'visible',
|
||||
slideDirection: node.slideDirection ?? 'left',
|
||||
operationState: node.operationState ?? 0,
|
||||
threshold: false,
|
||||
contentPadding: [0.035, 0.045],
|
||||
segments: foldingDoorSegments,
|
||||
}
|
||||
@@ -450,6 +460,7 @@ export function DoorPanel() {
|
||||
trackStyle: 'visible',
|
||||
slideDirection: node.slideDirection ?? 'left',
|
||||
operationState: node.operationState ?? 0,
|
||||
threshold: false,
|
||||
contentPadding: [0.03, 0.04],
|
||||
segments: frenchDoorSegments,
|
||||
}
|
||||
@@ -463,6 +474,7 @@ export function DoorPanel() {
|
||||
...dimensionUpdates,
|
||||
handle: false,
|
||||
threshold: false,
|
||||
openingShape: 'rectangle',
|
||||
trackStyle: 'overhead',
|
||||
operationState: 0,
|
||||
garagePanelCount: Math.max(3, Math.min(8, node.garagePanelCount ?? 4)),
|
||||
@@ -479,6 +491,7 @@ export function DoorPanel() {
|
||||
...dimensionUpdates,
|
||||
handle: false,
|
||||
threshold: false,
|
||||
openingShape: 'rectangle',
|
||||
trackStyle: 'overhead',
|
||||
operationState: 0,
|
||||
garagePanelCount: 4,
|
||||
@@ -495,6 +508,7 @@ export function DoorPanel() {
|
||||
...dimensionUpdates,
|
||||
handle: false,
|
||||
threshold: false,
|
||||
openingShape: 'rectangle',
|
||||
trackStyle: 'overhead',
|
||||
operationState: 0,
|
||||
garagePanelCount: 4,
|
||||
@@ -746,7 +760,7 @@ export function DoorPanel() {
|
||||
/>
|
||||
</PanelSection>
|
||||
|
||||
{!isOpening && (
|
||||
{!isOpening && supportsTopShape && (
|
||||
<PanelSection title="Top Shape">
|
||||
<div className="flex flex-col gap-2 px-1 pb-1">
|
||||
<SegmentedControl
|
||||
@@ -970,73 +984,75 @@ export function DoorPanel() {
|
||||
/>
|
||||
</PanelSection>
|
||||
|
||||
{!isGarageDoor && (
|
||||
<PanelSection title="Content Padding">
|
||||
<SliderControl
|
||||
label="Horizontal"
|
||||
max={0.2}
|
||||
min={0}
|
||||
onChange={(v) => handleUpdate({ contentPadding: [v, node.contentPadding[1]] })}
|
||||
precision={3}
|
||||
step={0.005}
|
||||
unit="m"
|
||||
value={Math.round(node.contentPadding[0] * 1000) / 1000}
|
||||
/>
|
||||
<SliderControl
|
||||
label="Vertical"
|
||||
max={0.2}
|
||||
min={0}
|
||||
onChange={(v) => handleUpdate({ contentPadding: [node.contentPadding[0], v] })}
|
||||
precision={3}
|
||||
step={0.005}
|
||||
unit="m"
|
||||
value={Math.round(node.contentPadding[1] * 1000) / 1000}
|
||||
/>
|
||||
</PanelSection>
|
||||
)}
|
||||
{!isGarageDoor && (
|
||||
<PanelSection title="Content Padding">
|
||||
<SliderControl
|
||||
label="Horizontal"
|
||||
max={0.2}
|
||||
min={0}
|
||||
onChange={(v) => handleUpdate({ contentPadding: [v, node.contentPadding[1]] })}
|
||||
precision={3}
|
||||
step={0.005}
|
||||
unit="m"
|
||||
value={Math.round(node.contentPadding[0] * 1000) / 1000}
|
||||
/>
|
||||
<SliderControl
|
||||
label="Vertical"
|
||||
max={0.2}
|
||||
min={0}
|
||||
onChange={(v) => handleUpdate({ contentPadding: [node.contentPadding[0], v] })}
|
||||
precision={3}
|
||||
step={0.005}
|
||||
unit="m"
|
||||
value={Math.round(node.contentPadding[1] * 1000) / 1000}
|
||||
/>
|
||||
</PanelSection>
|
||||
)}
|
||||
|
||||
{isSwingDoor && (
|
||||
<PanelSection title="Swing">
|
||||
<div className="flex flex-col gap-2 px-1 pb-1">
|
||||
<div className="space-y-1">
|
||||
<span className="font-medium text-[10px] text-muted-foreground/80 uppercase tracking-wider">
|
||||
Hinges Side
|
||||
</span>
|
||||
<SegmentedControl
|
||||
onChange={(v) => handleUpdate({ hingesSide: v })}
|
||||
options={[
|
||||
{ label: 'Left', value: 'left' },
|
||||
{ label: 'Right', value: 'right' },
|
||||
]}
|
||||
value={node.hingesSide}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<span className="font-medium text-[10px] text-muted-foreground/80 uppercase tracking-wider">
|
||||
Direction
|
||||
</span>
|
||||
<SegmentedControl
|
||||
onChange={(v) => handleUpdate({ swingDirection: v })}
|
||||
options={[
|
||||
{ label: 'Inward', value: 'inward' },
|
||||
{ label: 'Outward', value: 'outward' },
|
||||
]}
|
||||
value={node.swingDirection}
|
||||
/>
|
||||
</div>
|
||||
{isSwingDoor && (
|
||||
<PanelSection title="Swing">
|
||||
<div className="flex flex-col gap-2 px-1 pb-1">
|
||||
{supportsHingeSide && (
|
||||
<div className="space-y-1">
|
||||
<span className="font-medium text-[10px] text-muted-foreground/80 uppercase tracking-wider">
|
||||
Hinges Side
|
||||
</span>
|
||||
<SegmentedControl
|
||||
onChange={(v) => handleUpdate({ hingesSide: v })}
|
||||
options={[
|
||||
{ label: 'Left', value: 'left' },
|
||||
{ label: 'Right', value: 'right' },
|
||||
]}
|
||||
value={node.hingesSide}
|
||||
/>
|
||||
</div>
|
||||
</PanelSection>
|
||||
)}
|
||||
|
||||
{isSwingDoor && (
|
||||
<PanelSection title="Threshold">
|
||||
<ToggleControl
|
||||
checked={node.threshold}
|
||||
label="Enable Threshold"
|
||||
onChange={(checked) => handleUpdate({ threshold: checked })}
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
<span className="font-medium text-[10px] text-muted-foreground/80 uppercase tracking-wider">
|
||||
Direction
|
||||
</span>
|
||||
<SegmentedControl
|
||||
onChange={(v) => handleUpdate({ swingDirection: v })}
|
||||
options={[
|
||||
{ label: 'Inward', value: 'inward' },
|
||||
{ label: 'Outward', value: 'outward' },
|
||||
]}
|
||||
value={node.swingDirection}
|
||||
/>
|
||||
{node.threshold && (
|
||||
<div className="mt-1 flex flex-col gap-1">
|
||||
</div>
|
||||
</div>
|
||||
</PanelSection>
|
||||
)}
|
||||
|
||||
{isSwingDoor && (
|
||||
<PanelSection title="Threshold">
|
||||
<ToggleControl
|
||||
checked={node.threshold}
|
||||
label="Enable Threshold"
|
||||
onChange={(checked) => handleUpdate({ threshold: checked })}
|
||||
/>
|
||||
{node.threshold && (
|
||||
<div className="mt-1 flex flex-col gap-1">
|
||||
<SliderControl
|
||||
label="Height"
|
||||
max={0.1}
|
||||
|
||||
@@ -23,6 +23,7 @@ import { MaterialPicker } from '../controls/material-picker'
|
||||
import { PanelSection } from '../controls/panel-section'
|
||||
import { SegmentedControl } from '../controls/segmented-control'
|
||||
import { SliderControl } from '../controls/slider-control'
|
||||
import { ToggleControl } from '../controls/toggle-control'
|
||||
import { PanelWrapper } from './panel-wrapper'
|
||||
|
||||
type FenceStyleValue = 'slat' | 'rail' | 'privacy'
|
||||
@@ -110,6 +111,12 @@ export function FencePanel() {
|
||||
options={FENCE_BASE_STYLE_OPTIONS}
|
||||
value={node.baseStyle}
|
||||
/>
|
||||
<ToggleControl
|
||||
checked={node.showInfill ?? true}
|
||||
className="mt-2"
|
||||
label="Fence Infill"
|
||||
onChange={(checked) => handleUpdate({ showInfill: checked })}
|
||||
/>
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Dimensions">
|
||||
|
||||
@@ -4,60 +4,28 @@ import {
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
getClampedWallCurveOffset,
|
||||
getEffectiveWallSurfaceMaterial,
|
||||
getMaxWallCurveOffset,
|
||||
getWallCurveLength,
|
||||
getWallSurfaceMaterialSignature,
|
||||
type MaterialSchema,
|
||||
normalizeWallCurveOffset,
|
||||
useScene,
|
||||
type WallNode,
|
||||
type WallSurfaceSide,
|
||||
} from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { Move, Spline } from 'lucide-react'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { useCallback } from 'react'
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
import useEditor from '../../../store/use-editor'
|
||||
import { ActionButton, ActionGroup } from '../controls/action-button'
|
||||
import { MaterialPicker } from '../controls/material-picker'
|
||||
import { PanelSection } from '../controls/panel-section'
|
||||
import { SliderControl } from '../controls/slider-control'
|
||||
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() {
|
||||
const selectedId = useViewer((s) => s.selection.selectedIds[0])
|
||||
const setSelection = useViewer((s) => s.setSelection)
|
||||
const updateNode = useScene((s) => s.updateNode)
|
||||
const setMovingNode = useEditor((s) => s.setMovingNode)
|
||||
const setCurvingWall = useEditor((s) => s.setCurvingWall)
|
||||
const selectedMaterialTarget = useEditor((s) => s.selectedMaterialTarget)
|
||||
|
||||
const node = useScene((s) =>
|
||||
selectedId ? (s.nodes[selectedId as AnyNode['id']] as WallNode | undefined) : undefined,
|
||||
@@ -88,35 +56,6 @@ export function WallPanel() {
|
||||
[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(
|
||||
(newLength: number) => {
|
||||
if (!node || newLength <= 0) return
|
||||
@@ -140,24 +79,6 @@ export function WallPanel() {
|
||||
[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(() => {
|
||||
setSelection({ selectedIds: [] })
|
||||
}, [setSelection])
|
||||
@@ -239,23 +160,6 @@ export function WallPanel() {
|
||||
)}
|
||||
</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">
|
||||
<ActionGroup>
|
||||
<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 useEditor from '../../../store/use-editor'
|
||||
import { ActionButton, ActionGroup } from '../controls/action-button'
|
||||
import { MetricControl } from '../controls/metric-control'
|
||||
import { PanelSection } from '../controls/panel-section'
|
||||
import { SegmentedControl } from '../controls/segmented-control'
|
||||
import { SliderControl } from '../controls/slider-control'
|
||||
@@ -81,14 +80,16 @@ const windowTypeOptions: Array<{ label: string; value: WindowNode['windowType']
|
||||
{ label: 'Louvered', value: 'louvered' },
|
||||
]
|
||||
|
||||
const rectangleOnlyWindowTypes = new Set<WindowNode['windowType']>([
|
||||
'sliding',
|
||||
'single-hung',
|
||||
'double-hung',
|
||||
'bay',
|
||||
'bow',
|
||||
const shapedWindowTypes = new Set<WindowNode['windowType']>([
|
||||
'fixed',
|
||||
'casement',
|
||||
'awning',
|
||||
'hopper',
|
||||
'louvered',
|
||||
])
|
||||
|
||||
const silllessWindowTypes = new Set<WindowNode['windowType']>(['bay', 'bow'])
|
||||
|
||||
export function WindowPanel() {
|
||||
const selectedId = useViewer((s) => s.selection.selectedIds[0])
|
||||
const setSelection = useViewer((s) => s.setSelection)
|
||||
@@ -110,14 +111,19 @@ export function WindowPanel() {
|
||||
const handleUpdate = useCallback(
|
||||
(updates: Partial<WindowNode>) => {
|
||||
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 currentValue = node[key as keyof WindowNode]
|
||||
const currentValue = liveNode[key as keyof WindowNode]
|
||||
return !isSameWindowValue(currentValue, value)
|
||||
})
|
||||
if (!hasChange) return
|
||||
|
||||
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],
|
||||
)
|
||||
@@ -321,6 +327,9 @@ export function WindowPanel() {
|
||||
node.windowType === 'single-hung' ||
|
||||
node.windowType === 'double-hung' ||
|
||||
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) => {
|
||||
useInteractive.getState().cancelWindowAnimation(node.id)
|
||||
@@ -472,10 +481,10 @@ export function WindowPanel() {
|
||||
handleUpdate({
|
||||
windowType: option.value,
|
||||
...(option.value === 'awning' ? { awningDirection } : {}),
|
||||
...(rectangleOnlyWindowTypes.has(option.value)
|
||||
...(!shapedWindowTypes.has(option.value)
|
||||
? { openingShape: 'rectangle' }
|
||||
: {}),
|
||||
...(option.value === 'bay' || option.value === 'bow' ? { sill: false } : {}),
|
||||
...(silllessWindowTypes.has(option.value) ? { sill: false } : {}),
|
||||
})
|
||||
}
|
||||
type="button"
|
||||
@@ -605,7 +614,7 @@ export function WindowPanel() {
|
||||
/>
|
||||
</PanelSection>
|
||||
|
||||
{!(isOpening || rectangleOnlyWindowTypes.has(node.windowType)) && (
|
||||
{!isOpening && supportsWindowShape && (
|
||||
<PanelSection title="Corner Shape">
|
||||
<SegmentedControl
|
||||
onChange={(value) =>
|
||||
@@ -822,128 +831,132 @@ export function WindowPanel() {
|
||||
/>
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Grid">
|
||||
<SliderControl
|
||||
label="Columns"
|
||||
max={8}
|
||||
min={1}
|
||||
onChange={(v) => {
|
||||
const n = Math.max(1, Math.min(8, Math.round(v)))
|
||||
handleUpdate({ columnRatios: Array(n).fill(1 / n) })
|
||||
}}
|
||||
precision={0}
|
||||
step={1}
|
||||
value={numCols}
|
||||
/>
|
||||
<SliderControl
|
||||
label="Rows"
|
||||
max={8}
|
||||
min={1}
|
||||
onChange={(v) => {
|
||||
const n = Math.max(1, Math.min(8, Math.round(v)))
|
||||
handleUpdate({ rowRatios: Array(n).fill(1 / n) })
|
||||
}}
|
||||
precision={0}
|
||||
step={1}
|
||||
value={numRows}
|
||||
/>
|
||||
{supportsGrid && (
|
||||
<PanelSection title="Grid">
|
||||
<SliderControl
|
||||
label="Columns"
|
||||
max={8}
|
||||
min={1}
|
||||
onChange={(v) => {
|
||||
const n = Math.max(1, Math.min(8, Math.round(v)))
|
||||
handleUpdate({ columnRatios: Array(n).fill(1 / n) })
|
||||
}}
|
||||
precision={0}
|
||||
step={1}
|
||||
value={numCols}
|
||||
/>
|
||||
<SliderControl
|
||||
label="Rows"
|
||||
max={8}
|
||||
min={1}
|
||||
onChange={(v) => {
|
||||
const n = Math.max(1, Math.min(8, Math.round(v)))
|
||||
handleUpdate({ rowRatios: Array(n).fill(1 / n) })
|
||||
}}
|
||||
precision={0}
|
||||
step={1}
|
||||
value={numRows}
|
||||
/>
|
||||
|
||||
{numCols > 1 && (
|
||||
<div className="mt-2 flex flex-col gap-1">
|
||||
<div className="mb-1 px-1 font-medium text-[10px] text-muted-foreground/80 uppercase tracking-wider">
|
||||
Col Widths
|
||||
{numCols > 1 && (
|
||||
<div className="mt-2 flex flex-col gap-1">
|
||||
<div className="mb-1 px-1 font-medium text-[10px] text-muted-foreground/80 uppercase tracking-wider">
|
||||
Col Widths
|
||||
</div>
|
||||
{normCols.map((ratio, i) => (
|
||||
<SliderControl
|
||||
key={`c-${i}`}
|
||||
label={`C${i + 1}`}
|
||||
max={95}
|
||||
min={5}
|
||||
onChange={(v) => setColumnRatio(i, v / 100)}
|
||||
precision={1}
|
||||
step={1}
|
||||
unit="%"
|
||||
value={Math.round(ratio * 100 * 10) / 10}
|
||||
/>
|
||||
))}
|
||||
<div className="mt-1 border-border/50 border-t pt-1">
|
||||
<SliderControl
|
||||
label="Divider"
|
||||
max={0.1}
|
||||
min={0.005}
|
||||
onChange={(v) => handleUpdate({ columnDividerThickness: v })}
|
||||
precision={3}
|
||||
step={0.01}
|
||||
unit="m"
|
||||
value={Math.round((node.columnDividerThickness ?? 0.03) * 1000) / 1000}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{normCols.map((ratio, i) => (
|
||||
)}
|
||||
|
||||
{numRows > 1 && (
|
||||
<div className="mt-2 flex flex-col gap-1">
|
||||
<div className="mb-1 px-1 font-medium text-[10px] text-muted-foreground/80 uppercase tracking-wider">
|
||||
Row Heights
|
||||
</div>
|
||||
{normRows.map((ratio, i) => (
|
||||
<SliderControl
|
||||
key={`r-${i}`}
|
||||
label={`R${i + 1}`}
|
||||
max={95}
|
||||
min={5}
|
||||
onChange={(v) => setRowRatio(i, v / 100)}
|
||||
precision={1}
|
||||
step={1}
|
||||
unit="%"
|
||||
value={Math.round(ratio * 100 * 10) / 10}
|
||||
/>
|
||||
))}
|
||||
<div className="mt-1 border-border/50 border-t pt-1">
|
||||
<SliderControl
|
||||
label="Divider"
|
||||
max={0.1}
|
||||
min={0.005}
|
||||
onChange={(v) => handleUpdate({ rowDividerThickness: v })}
|
||||
precision={3}
|
||||
step={0.01}
|
||||
unit="m"
|
||||
value={Math.round((node.rowDividerThickness ?? 0.03) * 1000) / 1000}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</PanelSection>
|
||||
)}
|
||||
|
||||
{supportsSill && (
|
||||
<PanelSection title="Sill">
|
||||
<ToggleControl
|
||||
checked={node.sill}
|
||||
label="Enable Sill"
|
||||
onChange={(checked) => handleUpdate({ sill: checked })}
|
||||
/>
|
||||
{node.sill && (
|
||||
<div className="mt-1 flex flex-col gap-1">
|
||||
<SliderControl
|
||||
key={`c-${i}`}
|
||||
label={`C${i + 1}`}
|
||||
max={95}
|
||||
min={5}
|
||||
onChange={(v) => setColumnRatio(i, v / 100)}
|
||||
precision={1}
|
||||
step={1}
|
||||
unit="%"
|
||||
value={Math.round(ratio * 100 * 10) / 10}
|
||||
/>
|
||||
))}
|
||||
<div className="mt-1 border-border/50 border-t pt-1">
|
||||
<SliderControl
|
||||
label="Divider"
|
||||
max={0.1}
|
||||
min={0.005}
|
||||
onChange={(v) => handleUpdate({ columnDividerThickness: v })}
|
||||
label="Depth"
|
||||
min={0}
|
||||
onChange={(v) => handleUpdate({ sillDepth: v })}
|
||||
precision={3}
|
||||
step={0.01}
|
||||
unit="m"
|
||||
value={Math.round((node.columnDividerThickness ?? 0.03) * 1000) / 1000}
|
||||
value={Math.round(node.sillDepth * 1000) / 1000}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{numRows > 1 && (
|
||||
<div className="mt-2 flex flex-col gap-1">
|
||||
<div className="mb-1 px-1 font-medium text-[10px] text-muted-foreground/80 uppercase tracking-wider">
|
||||
Row Heights
|
||||
</div>
|
||||
{normRows.map((ratio, i) => (
|
||||
<SliderControl
|
||||
key={`r-${i}`}
|
||||
label={`R${i + 1}`}
|
||||
max={95}
|
||||
min={5}
|
||||
onChange={(v) => setRowRatio(i, v / 100)}
|
||||
precision={1}
|
||||
step={1}
|
||||
unit="%"
|
||||
value={Math.round(ratio * 100 * 10) / 10}
|
||||
/>
|
||||
))}
|
||||
<div className="mt-1 border-border/50 border-t pt-1">
|
||||
<SliderControl
|
||||
label="Divider"
|
||||
max={0.1}
|
||||
min={0.005}
|
||||
onChange={(v) => handleUpdate({ rowDividerThickness: v })}
|
||||
label="Thickness"
|
||||
min={0}
|
||||
onChange={(v) => handleUpdate({ sillThickness: v })}
|
||||
precision={3}
|
||||
step={0.01}
|
||||
unit="m"
|
||||
value={Math.round((node.rowDividerThickness ?? 0.03) * 1000) / 1000}
|
||||
value={Math.round(node.sillThickness * 1000) / 1000}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Sill">
|
||||
<ToggleControl
|
||||
checked={node.sill}
|
||||
label="Enable Sill"
|
||||
onChange={(checked) => handleUpdate({ sill: checked })}
|
||||
/>
|
||||
{node.sill && (
|
||||
<div className="mt-1 flex flex-col gap-1">
|
||||
<SliderControl
|
||||
label="Depth"
|
||||
min={0}
|
||||
onChange={(v) => handleUpdate({ sillDepth: v })}
|
||||
precision={3}
|
||||
step={0.01}
|
||||
unit="m"
|
||||
value={Math.round(node.sillDepth * 1000) / 1000}
|
||||
/>
|
||||
<SliderControl
|
||||
label="Thickness"
|
||||
min={0}
|
||||
onChange={(v) => handleUpdate({ sillThickness: v })}
|
||||
precision={3}
|
||||
step={0.01}
|
||||
unit="m"
|
||||
value={Math.round(node.sillThickness * 1000) / 1000}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</PanelSection>
|
||||
)}
|
||||
</PanelSection>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -3,6 +3,10 @@ import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect } from 'react'
|
||||
import { closeDoorOpenState, toggleDoorOpenState } from '../lib/door-interaction'
|
||||
import { runRedo, runUndo } from '../lib/history'
|
||||
import {
|
||||
copySelectedNodesToEditorClipboard,
|
||||
pasteEditorClipboardToLevel,
|
||||
} from '../lib/scene-clipboard'
|
||||
import { sfxEmitter } from '../lib/sfx-bus'
|
||||
import { closeWindowOpenState, toggleWindowOpenState } from '../lib/window-interaction'
|
||||
import useEditor from '../store/use-editor'
|
||||
@@ -106,6 +110,17 @@ export const useKeyboard = ({
|
||||
useEditor.getState().setPhase('structure')
|
||||
useEditor.getState().setStructureLayer('elements')
|
||||
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)) {
|
||||
if (isVersionPreviewMode) return
|
||||
e.preventDefault()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type {
|
||||
CeilingNode,
|
||||
ColumnNode,
|
||||
DoorNode,
|
||||
ItemNode,
|
||||
Point2D,
|
||||
@@ -53,6 +54,11 @@ type CeilingEntry = {
|
||||
holes: Point2D[][]
|
||||
}
|
||||
|
||||
type ColumnEntry = {
|
||||
column: ColumnNode
|
||||
polygon: Point2D[]
|
||||
}
|
||||
|
||||
type RoofEntry = {
|
||||
roof: RoofNode
|
||||
segments: Array<{
|
||||
@@ -71,6 +77,7 @@ type FloorplanSelectionToolContext = {
|
||||
walls: WallEntry[]
|
||||
slabs: SlabEntry[]
|
||||
ceilings: CeilingEntry[]
|
||||
columns: ColumnEntry[]
|
||||
roofs: RoofEntry[]
|
||||
openingHitTolerance: number
|
||||
wallHitTolerance: number
|
||||
@@ -123,6 +130,13 @@ export function getFloorplanHitNodeId(context: FloorplanSelectionToolContext) {
|
||||
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(
|
||||
({ wall, polygon }) =>
|
||||
isPointInsidePolygon(context.point, polygon) ||
|
||||
@@ -166,6 +180,7 @@ type FloorplanSelectionBoundsContext = {
|
||||
openings: OpeningPolygonEntry[]
|
||||
slabs: SlabEntry[]
|
||||
ceilings: CeilingEntry[]
|
||||
columns: ColumnEntry[]
|
||||
stairs: StairEntry[]
|
||||
roofs: RoofEntry[]
|
||||
}
|
||||
@@ -179,6 +194,7 @@ export function getFloorplanSelectionIdsInBounds({
|
||||
openings,
|
||||
slabs,
|
||||
ceilings,
|
||||
columns,
|
||||
stairs,
|
||||
roofs,
|
||||
}: FloorplanSelectionBoundsContext) {
|
||||
@@ -204,6 +220,9 @@ export function getFloorplanSelectionIdsInBounds({
|
||||
const ceilingIds = ceilings
|
||||
.filter(({ polygon }) => doesPolygonIntersectSelectionBounds(polygon, bounds))
|
||||
.map(({ ceiling }) => ceiling.id)
|
||||
const columnIds = columns
|
||||
.filter(({ polygon }) => doesPolygonIntersectSelectionBounds(polygon, bounds))
|
||||
.map(({ column }) => column.id)
|
||||
const stairIds = stairs
|
||||
.filter((stair) =>
|
||||
getStairHitPolygons(stair).some((polygon) =>
|
||||
@@ -224,6 +243,7 @@ export function getFloorplanSelectionIdsInBounds({
|
||||
...openingIds,
|
||||
...slabIds,
|
||||
...ceilingIds,
|
||||
...columnIds,
|
||||
...stairIds,
|
||||
...roofIds,
|
||||
]),
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,6 @@
|
||||
"three": "^0.184"
|
||||
},
|
||||
"dependencies": {
|
||||
"polygon-clipping": "^0.15.7",
|
||||
"three-bvh-csg": "^0.0.18",
|
||||
"three-mesh-bvh": "^0.9.8",
|
||||
"zustand": "^5"
|
||||
|
||||
@@ -4,12 +4,19 @@ import {
|
||||
resolveMaterial,
|
||||
useRegistry,
|
||||
} 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 { BackSide, FrontSide, type Mesh, MeshBasicNodeMaterial } from 'three/webgpu'
|
||||
import { useNodeEvents } from '../../../hooks/use-node-events'
|
||||
import { NodeRenderer } from '../node-renderer'
|
||||
|
||||
function createEmptyGeometry() {
|
||||
const geometry = new BufferGeometry()
|
||||
geometry.setAttribute('position', new Float32BufferAttribute([], 3))
|
||||
return geometry
|
||||
}
|
||||
|
||||
const gridScale = 5
|
||||
const gridX = positionWorld.x.mul(gridScale).fract()
|
||||
const gridY = positionWorld.z.mul(gridScale).fract()
|
||||
@@ -51,10 +58,20 @@ function getCeilingMaterials(color = '#999999') {
|
||||
|
||||
export const CeilingRenderer = ({ node }: { node: CeilingNode }) => {
|
||||
const ref = useRef<Mesh>(null!)
|
||||
const placeholderGeometry = useMemo(createEmptyGeometry, [])
|
||||
const gridPlaceholderGeometry = useMemo(createEmptyGeometry, [])
|
||||
|
||||
useRegistry(node.id, 'ceiling', ref)
|
||||
const handlers = useNodeEvents(node, 'ceiling')
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
placeholderGeometry.dispose()
|
||||
gridPlaceholderGeometry.dispose()
|
||||
},
|
||||
[gridPlaceholderGeometry, placeholderGeometry],
|
||||
)
|
||||
|
||||
const materials = useMemo(() => {
|
||||
const preset = getMaterialPresetByRef(node.materialPreset)
|
||||
const props = preset?.mapProperties ?? resolveMaterial(node.material)
|
||||
@@ -69,17 +86,15 @@ export const CeilingRenderer = ({ node }: { node: CeilingNode }) => {
|
||||
])
|
||||
|
||||
return (
|
||||
<mesh material={materials.bottomMaterial} ref={ref}>
|
||||
<boxGeometry args={[0, 0, 0]} />
|
||||
<mesh geometry={placeholderGeometry} material={materials.bottomMaterial} ref={ref}>
|
||||
<mesh
|
||||
geometry={gridPlaceholderGeometry}
|
||||
material={materials.topMaterial}
|
||||
name="ceiling-grid"
|
||||
{...handlers}
|
||||
scale={0}
|
||||
visible={false}
|
||||
>
|
||||
<boxGeometry args={[0, 0, 0]} />
|
||||
</mesh>
|
||||
/>
|
||||
{node.children.map((childId) => (
|
||||
<NodeRenderer key={childId} nodeId={childId} />
|
||||
))}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { type ColumnNode, useLiveTransforms, useRegistry } from '@pascal-app/core'
|
||||
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 { baseMaterial, createMaterial, createMaterialFromPresetRef } from '../../../lib/materials'
|
||||
import {
|
||||
@@ -84,6 +84,10 @@ function getShaftScaleAt(node: ColumnNode, t: number) {
|
||||
|
||||
type VectorTuple = [number, number, number]
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(max, Math.max(min, value))
|
||||
}
|
||||
|
||||
function MappedBox({
|
||||
depth,
|
||||
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({
|
||||
height,
|
||||
position,
|
||||
@@ -244,7 +847,14 @@ function MappedTorus({
|
||||
}) {
|
||||
const geometry = useMemo(() => {
|
||||
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])
|
||||
|
||||
if (!geometry) return null
|
||||
@@ -1449,7 +2059,11 @@ export const ColumnRenderer = ({ node }: { node: ColumnNode }) => {
|
||||
const handlers = useNodeEvents(node, 'column')
|
||||
const liveTransform = useLiveTransforms((state) => state.get(node.id))
|
||||
const material = useMemo(
|
||||
() => createColumnMaterial({ material: node.material, materialPreset: node.materialPreset }),
|
||||
() =>
|
||||
createColumnMaterial({
|
||||
material: node.material,
|
||||
materialPreset: node.materialPreset,
|
||||
}),
|
||||
[
|
||||
node.material,
|
||||
node.material?.preset,
|
||||
@@ -1479,41 +2093,73 @@ export const ColumnRenderer = ({ node }: { node: ColumnNode }) => {
|
||||
visible={node.visible}
|
||||
{...handlers}
|
||||
>
|
||||
<Base height={shaftLayout.baseHeight} node={node} />
|
||||
<BaseCarvings height={shaftLayout.baseHeight} node={node} />
|
||||
<Shaft height={shaftLayout.shaftHeight} node={node} y={shaftLayout.shaftY} />
|
||||
<Rings node={node} shaftHeight={shaftLayout.shaftHeight} shaftY={shaftLayout.shaftY} />
|
||||
<LatheBands
|
||||
node={node}
|
||||
shaftHeight={shaftLayout.shaftHeight}
|
||||
shaftY={shaftLayout.shaftY}
|
||||
/>
|
||||
<Flutes node={node} shaftHeight={shaftLayout.shaftHeight} shaftY={shaftLayout.shaftY} />
|
||||
<LowerCarvedBand
|
||||
node={node}
|
||||
shaftHeight={shaftLayout.shaftHeight}
|
||||
shaftY={shaftLayout.shaftY}
|
||||
/>
|
||||
<DravidianShaftPanels
|
||||
node={node}
|
||||
shaftHeight={shaftLayout.shaftHeight}
|
||||
shaftY={shaftLayout.shaftY}
|
||||
/>
|
||||
<SpiralRibs
|
||||
node={node}
|
||||
shaftHeight={shaftLayout.shaftHeight}
|
||||
shaftY={shaftLayout.shaftY}
|
||||
/>
|
||||
<Capital
|
||||
height={shaftLayout.capitalHeight}
|
||||
node={node}
|
||||
y={shaftLayout.baseHeight + shaftLayout.shaftHeight}
|
||||
/>
|
||||
<CapitalCarvings
|
||||
capitalHeight={shaftLayout.capitalHeight}
|
||||
capitalY={shaftLayout.baseHeight + shaftLayout.shaftHeight}
|
||||
node={node}
|
||||
/>
|
||||
{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} />
|
||||
<BaseCarvings height={shaftLayout.baseHeight} node={node} />
|
||||
<Shaft height={shaftLayout.shaftHeight} node={node} y={shaftLayout.shaftY} />
|
||||
<Rings
|
||||
node={node}
|
||||
shaftHeight={shaftLayout.shaftHeight}
|
||||
shaftY={shaftLayout.shaftY}
|
||||
/>
|
||||
<LatheBands
|
||||
node={node}
|
||||
shaftHeight={shaftLayout.shaftHeight}
|
||||
shaftY={shaftLayout.shaftY}
|
||||
/>
|
||||
<Flutes
|
||||
node={node}
|
||||
shaftHeight={shaftLayout.shaftHeight}
|
||||
shaftY={shaftLayout.shaftY}
|
||||
/>
|
||||
<LowerCarvedBand
|
||||
node={node}
|
||||
shaftHeight={shaftLayout.shaftHeight}
|
||||
shaftY={shaftLayout.shaftY}
|
||||
/>
|
||||
<DravidianShaftPanels
|
||||
node={node}
|
||||
shaftHeight={shaftLayout.shaftHeight}
|
||||
shaftY={shaftLayout.shaftY}
|
||||
/>
|
||||
<SpiralRibs
|
||||
node={node}
|
||||
shaftHeight={shaftLayout.shaftHeight}
|
||||
shaftY={shaftLayout.shaftY}
|
||||
/>
|
||||
<Capital
|
||||
height={shaftLayout.capitalHeight}
|
||||
node={node}
|
||||
y={shaftLayout.baseHeight + shaftLayout.shaftHeight}
|
||||
/>
|
||||
<CapitalCarvings
|
||||
capitalHeight={shaftLayout.capitalHeight}
|
||||
capitalY={shaftLayout.baseHeight + shaftLayout.shaftHeight}
|
||||
node={node}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</group>
|
||||
</ColumnEdgeSoftnessContext.Provider>
|
||||
</ColumnMaterialContext.Provider>
|
||||
|
||||
@@ -156,9 +156,12 @@ const ModelRenderer = ({ node }: { node: ItemNode }) => {
|
||||
const lightEffects =
|
||||
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 (
|
||||
<>
|
||||
<Clone
|
||||
dispose={null}
|
||||
object={scene}
|
||||
position={node.asset.offset}
|
||||
ref={ref}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { type SiteNode, type SlabNode, useRegistry, useScene } from '@pascal-app/core'
|
||||
import polygonClipping from 'polygon-clipping'
|
||||
import { useMemo, useRef } from 'react'
|
||||
import { BufferGeometry, Float32BufferAttribute, type Group, Path, Shape } from 'three'
|
||||
import { useNodeEvents } from '../../../hooks/use-node-events'
|
||||
@@ -88,24 +87,16 @@ export const SiteRenderer = ({ node }: { node: SiteNode }) => {
|
||||
for (let i = 1; i < pts.length; i++) shape.lineTo(pts[i]![0], -pts[i]![1])
|
||||
shape.closePath()
|
||||
|
||||
if (slabPolygons.length > 0) {
|
||||
const multiPolygons = slabPolygons.map((p) => [
|
||||
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()
|
||||
hole.moveTo(ring[0]![0], ring[0]![1])
|
||||
for (let i = 1; i < ring.length; i++) hole.lineTo(ring[i]![0], ring[i]![1])
|
||||
hole.closePath()
|
||||
shape.holes.push(hole)
|
||||
}
|
||||
for (const polygon of slabPolygons) {
|
||||
if (polygon.length < 3) continue
|
||||
|
||||
const hole = new Path()
|
||||
hole.moveTo(polygon[0]![0], -polygon[0]![1])
|
||||
for (let i = 1; i < polygon.length; i++) {
|
||||
hole.lineTo(polygon[i]![0], -polygon[i]![1])
|
||||
}
|
||||
hole.closePath()
|
||||
shape.holes.push(hole)
|
||||
}
|
||||
|
||||
return shape
|
||||
|
||||
@@ -11,6 +11,12 @@ import {
|
||||
|
||||
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(
|
||||
cacheKey: string,
|
||||
params: { material?: SlabNode['material']; materialPreset?: string },
|
||||
@@ -47,11 +53,14 @@ function getSlabMaterial(
|
||||
|
||||
export const SlabRenderer = ({ node }: { node: SlabNode }) => {
|
||||
const ref = useRef<Mesh>(null!)
|
||||
const placeholderGeometry = useMemo(createEmptyGeometry, [])
|
||||
|
||||
useRegistry(node.id, 'slab', ref)
|
||||
|
||||
const handlers = useNodeEvents(node, 'slab')
|
||||
|
||||
useEffect(() => () => placeholderGeometry.dispose(), [placeholderGeometry])
|
||||
|
||||
const material = useMemo(() => {
|
||||
const resolvedMaterial = node.material
|
||||
const resolvedMaterialPreset = node.materialPreset
|
||||
@@ -75,13 +84,12 @@ export const SlabRenderer = ({ node }: { node: SlabNode }) => {
|
||||
return (
|
||||
<mesh
|
||||
castShadow
|
||||
geometry={placeholderGeometry}
|
||||
receiveShadow
|
||||
ref={ref}
|
||||
{...handlers}
|
||||
material={material}
|
||||
visible={node.visible}
|
||||
>
|
||||
<boxGeometry args={[0, 0, 0]} />
|
||||
</mesh>
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,12 +1,27 @@
|
||||
import { useRegistry, useScene, type WallNode } from '@pascal-app/core'
|
||||
import { useLayoutEffect, useRef } from 'react'
|
||||
import type { Mesh } from 'three'
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef } from 'react'
|
||||
import { BufferGeometry, Float32BufferAttribute, type Mesh } from 'three'
|
||||
import { useNodeEvents } from '../../../hooks/use-node-events'
|
||||
import { getVisibleWallMaterials } from '../../../systems/wall/wall-materials'
|
||||
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 }) => {
|
||||
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)
|
||||
|
||||
@@ -14,15 +29,31 @@ export const WallRenderer = ({ node }: { node: WallNode }) => {
|
||||
useScene.getState().markDirty(node.id)
|
||||
}, [node.id])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
placeholderGeometry.dispose()
|
||||
collisionPlaceholderGeometry.dispose()
|
||||
}
|
||||
}, [collisionPlaceholderGeometry, placeholderGeometry])
|
||||
|
||||
const handlers = useNodeEvents(node, 'wall')
|
||||
const material = getVisibleWallMaterials(node)
|
||||
|
||||
return (
|
||||
<mesh castShadow material={material} receiveShadow ref={ref} visible={node.visible}>
|
||||
<boxGeometry args={[0, 0, 0]} />
|
||||
<mesh name="collision-mesh" visible={false} {...handlers}>
|
||||
<boxGeometry args={[0, 0, 0]} />
|
||||
</mesh>
|
||||
<mesh
|
||||
castShadow
|
||||
geometry={placeholderGeometry}
|
||||
material={material}
|
||||
receiveShadow
|
||||
ref={ref}
|
||||
visible={node.visible}
|
||||
>
|
||||
<mesh
|
||||
geometry={collisionPlaceholderGeometry}
|
||||
name="collision-mesh"
|
||||
visible={false}
|
||||
{...handlers}
|
||||
/>
|
||||
|
||||
{node.children.map((childId) => (
|
||||
<NodeRenderer key={`${node.id}:${childId}`} nodeId={childId} />
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { type LevelNode, useScene } from '@pascal-app/core'
|
||||
import polygonClipping from 'polygon-clipping'
|
||||
import { useMemo } from 'react'
|
||||
import * as THREE from 'three'
|
||||
import useViewer from '../../store/use-viewer'
|
||||
@@ -63,33 +62,16 @@ export const GroundOccluder = () => {
|
||||
polygons.push(node.polygon as [number, number][])
|
||||
})
|
||||
|
||||
if (polygons.length > 0) {
|
||||
// Format for polygon-clipping: [[[x, y], [x, y], ...]]
|
||||
const multiPolygons = polygons.map((pts) => {
|
||||
const ring = pts.map((p) => [p[0], -p[1]] as [number, number]) // Negate Y (which was Z)
|
||||
return [ring]
|
||||
})
|
||||
for (const polygon of polygons) {
|
||||
if (polygon.length < 3) continue
|
||||
|
||||
// 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()
|
||||
|
||||
if (ring.length > 0) {
|
||||
hole.moveTo(ring[0]![0], ring[0]![1])
|
||||
for (let i = 1; i < ring.length; i++) {
|
||||
hole.lineTo(ring[i]![0], ring[i]![1])
|
||||
}
|
||||
hole.closePath()
|
||||
s.holes.push(hole)
|
||||
}
|
||||
}
|
||||
const hole = new THREE.Path()
|
||||
hole.moveTo(polygon[0]![0], -polygon[0]![1])
|
||||
for (let i = 1; i < polygon.length; i++) {
|
||||
hole.lineTo(polygon[i]![0], -polygon[i]![1])
|
||||
}
|
||||
hole.closePath()
|
||||
s.holes.push(hole)
|
||||
}
|
||||
|
||||
return s
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
'use client'
|
||||
|
||||
import { Bvh } from '@react-three/drei'
|
||||
import { Canvas, extend, type ThreeToJSXElements, useFrame, useThree } from '@react-three/fiber'
|
||||
import { useEffect, useMemo, useRef } from 'react'
|
||||
import * as THREE from 'three/webgpu'
|
||||
@@ -28,6 +27,7 @@ import FrameLimiter from './frame-limiter'
|
||||
import { Lights } from './lights'
|
||||
import { PerfMonitor } from './perf-monitor'
|
||||
import PostProcessing, { DEFAULT_HOVER_STYLES, type HoverStyles } from './post-processing'
|
||||
import { SceneBvh } from './scene-bvh'
|
||||
import { SelectionManager } from './selection-manager'
|
||||
import { ViewerCamera } from './viewer-camera'
|
||||
|
||||
@@ -219,9 +219,9 @@ const Viewer: React.FC<ViewerProps> = ({
|
||||
/> */}
|
||||
<Lights />
|
||||
{useBvh ? (
|
||||
<Bvh>
|
||||
<SceneBvh>
|
||||
<SceneRenderer />
|
||||
</Bvh>
|
||||
</SceneBvh>
|
||||
) : (
|
||||
<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'
|
||||
@@ -282,16 +282,18 @@ export function createMaterial(material?: MaterialSchema): THREE.MeshStandardMat
|
||||
}
|
||||
|
||||
const map = getTexture(material)
|
||||
|
||||
const threeMaterial = new THREE.MeshStandardMaterial({
|
||||
const materialParams: THREE.MeshStandardMaterialParameters = {
|
||||
color: props.color,
|
||||
roughness: props.roughness,
|
||||
metalness: props.metalness,
|
||||
opacity: props.opacity,
|
||||
transparent: props.transparent,
|
||||
side: sideMap[props.side],
|
||||
map,
|
||||
})
|
||||
}
|
||||
|
||||
if (map) materialParams.map = map
|
||||
|
||||
const threeMaterial = new THREE.MeshStandardMaterial(materialParams)
|
||||
|
||||
materialCache.set(cacheKey, threeMaterial)
|
||||
return threeMaterial
|
||||
|
||||
@@ -50,7 +50,7 @@ function updateCeilingGeometry(node: CeilingNode, mesh: THREE.Mesh) {
|
||||
const gridMesh = mesh.getObjectByName('ceiling-grid') as THREE.Mesh
|
||||
if (gridMesh) {
|
||||
gridMesh.geometry.dispose()
|
||||
gridMesh.geometry = newGeo
|
||||
gridMesh.geometry = newGeo.clone()
|
||||
}
|
||||
|
||||
// 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 edgeInset = Math.max(fence.edgeInset ?? 0.015, 0.005)
|
||||
const isFloating = fence.baseStyle === 'floating'
|
||||
const showInfill = fence.showInfill ?? true
|
||||
const baseY = isFloating ? clearance : 0
|
||||
const effectiveBaseHeight = baseHeight
|
||||
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
|
||||
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const t = count === 1 ? 0.5 : startInsetT + (endInsetT - startInsetT) * (index / (count - 1))
|
||||
const frame = getFencePointAt(fence, t)
|
||||
const isEdgePost = index === 0 || index === count - 1
|
||||
const postHeight =
|
||||
isFloating && isEdgePost
|
||||
? effectiveBaseHeight + verticalHeight + topRailHeight + clearance
|
||||
: verticalHeight
|
||||
const postY = isFloating && isEdgePost ? postHeight / 2 : verticalY
|
||||
const fullHeightPost = !showInfill || (isFloating && isEdgePost)
|
||||
const postHeight = fullHeightPost
|
||||
? effectiveBaseHeight + verticalHeight + topRailHeight + clearance
|
||||
: verticalHeight
|
||||
const postY = fullHeightPost ? postHeight / 2 : verticalY
|
||||
|
||||
parts.push({
|
||||
position: [frame.point.x, postY, frame.point.y],
|
||||
@@ -246,7 +247,9 @@ function generateFenceGeometry(fence: FenceNode) {
|
||||
const geometries = parts.map(createFencePartGeometry)
|
||||
|
||||
const merged = mergeGeometries(geometries, false) ?? new THREE.BufferGeometry()
|
||||
geometries.forEach((geometry) => geometry.dispose())
|
||||
geometries.forEach((geometry) => {
|
||||
geometry.dispose()
|
||||
})
|
||||
const mergedUv = merged.getAttribute('uv')
|
||||
if (mergedUv) {
|
||||
merged.setAttribute('uv2', new THREE.Float32BufferAttribute(Array.from(mergedUv.array), 2))
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { useFrame } from '@react-three/fiber'
|
||||
import { useEffect } from 'react'
|
||||
import * as THREE from 'three'
|
||||
|
||||
function ensureUv2Attribute(geometry: THREE.BufferGeometry) {
|
||||
@@ -22,6 +23,16 @@ function ensureUv2Attribute(geometry: THREE.BufferGeometry) {
|
||||
export const SlabSystem = () => {
|
||||
const dirtyNodes = useScene((state) => state.dirtyNodes)
|
||||
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(() => {
|
||||
if (dirtyNodes.size === 0) return
|
||||
|
||||
Reference in New Issue
Block a user