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