diff --git a/apps/editor/app/page.tsx b/apps/editor/app/page.tsx index 67924841d..80fb1a962 100644 --- a/apps/editor/app/page.tsx +++ b/apps/editor/app/page.tsx @@ -1,14 +1,12 @@ 'use client' -import { - Editor, - ItemsPanel, - type SidebarTab, - ViewerToolbarLeft, - ViewerToolbarRight, -} from '@pascal-app/editor' +import { Editor, ItemsPanel } from '@pascal-app/editor' import { Layers, Package, Settings } from 'lucide-react' import Link from 'next/link' +import { + CommunityViewerToolbarLeft, + CommunityViewerToolbarRight, +} from '@/components/viewer-toolbar' const SIDEBAR_TABS = [ { @@ -59,8 +57,8 @@ export default function Home() { layoutVersion="v2" projectId={PROJECT_ID} sidebarTabs={SIDEBAR_TABS} - viewerToolbarLeft={} - viewerToolbarRight={} + viewerToolbarLeft={} + viewerToolbarRight={} /> ) diff --git a/apps/editor/components/scene-loader.tsx b/apps/editor/components/scene-loader.tsx index 5546c032d..b3606f761 100644 --- a/apps/editor/components/scene-loader.tsx +++ b/apps/editor/components/scene-loader.tsx @@ -5,12 +5,11 @@ import { Editor, type SceneGraph, type SidebarTab, - ViewerToolbarLeft, - ViewerToolbarRight, } from '@pascal-app/editor' import Link from 'next/link' import { useRouter } from 'next/navigation' import { useCallback, useEffect, useRef, useState } from 'react' +import { CommunityViewerToolbarLeft, CommunityViewerToolbarRight } from './viewer-toolbar' export interface SceneMeta { id: string @@ -200,8 +199,8 @@ export function SceneLoader({ initialScene, meta }: SceneLoaderProps) { onThumbnailCapture={handleThumb} projectId={meta.projectId ?? 'default'} sidebarTabs={SIDEBAR_TABS} - viewerToolbarLeft={} - viewerToolbarRight={} + viewerToolbarLeft={} + viewerToolbarRight={} /> ) diff --git a/apps/editor/components/toolbar-tooltip.tsx b/apps/editor/components/toolbar-tooltip.tsx new file mode 100644 index 000000000..f3554d01a --- /dev/null +++ b/apps/editor/components/toolbar-tooltip.tsx @@ -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) { + return +} + +function Tooltip({ ...props }: React.ComponentProps) { + return ( + + + + ) +} + +function TooltipTrigger({ ...props }: React.ComponentProps) { + return +} + +function TooltipContent({ + className, + sideOffset = 6, + children, + ...props +}: React.ComponentProps) { + return ( + + + {children} + + + + ) +} + +export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } diff --git a/apps/editor/components/viewer-toolbar.tsx b/apps/editor/components/viewer-toolbar.tsx new file mode 100644 index 000000000..e3668ce28 --- /dev/null +++ b/apps/editor/components/viewer-toolbar.tsx @@ -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 ( + + {children} + {label} + + ) +} + +const VIEW_MODES: { id: ViewMode; label: string; icon: React.ReactNode }[] = [ + { + id: '3d', + label: '3D', + icon: ( + + ), + }, + { + id: '2d', + label: '2D', + icon: ( + + ), + }, + { + id: 'split', + label: 'Split', + icon: , + }, +] + +const levelModeOrder = ['stacked', 'exploded', 'solo'] as const +const levelModeLabels: Record = { + manual: 'Stack', + stacked: 'Stack', + exploded: 'Exploded', + solo: 'Solo', +} + +const wallModeOrder = ['cutaway', 'up', 'down'] as const +const wallModeConfig: Record = { + 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 ( +
+ {VIEW_MODES.map((mode) => { + const isActive = viewMode === mode.id + return ( + + + + ) + })} +
+ ) +} + +function CollapseSidebarButton() { + const isCollapsed = useSidebarStore((state) => state.isCollapsed) + const setIsCollapsed = useSidebarStore((state) => state.setIsCollapsed) + + const toggle = useCallback(() => { + setIsCollapsed(!isCollapsed) + }, [isCollapsed, setIsCollapsed]) + + return ( +
+ + + +
+ ) +} + +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 ( + + + + ) +} + +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 ( + + + + ) +} + +function GridVisibilityToggle() { + const showGrid = useViewer((state) => state.showGrid) + const setShowGrid = useViewer((state) => state.setShowGrid) + + return ( + + + + ) +} + +function UnitToggle() { + const unit = useViewer((state) => state.unit) + const setUnit = useViewer((state) => state.setUnit) + + return ( + + + + ) +} + +function ThemeToggle() { + const theme = useViewer((state) => state.theme) + const setTheme = useViewer((state) => state.setTheme) + + return ( + + + + ) +} + +function CameraModeToggle() { + const cameraMode = useViewer((state) => state.cameraMode) + const setCameraMode = useViewer((state) => state.setCameraMode) + + return ( + + + + ) +} + +function WalkthroughButton() { + const isFirstPersonMode = useEditor((state) => state.isFirstPersonMode) + const setFirstPersonMode = useEditor((state) => state.setFirstPersonMode) + + return ( + + + + ) +} + +function PreviewButton() { + return ( + + + + ) +} + +export function CommunityViewerToolbarLeft() { + return ( + <> + + + + ) +} + +export function CommunityViewerToolbarRight() { + return ( +
+ + + +
+ + + +
+ + +
+ ) +} diff --git a/apps/editor/package.json b/apps/editor/package.json index 21e8dad85..f2c00a2ed 100644 --- a/apps/editor/package.json +++ b/apps/editor/package.json @@ -11,11 +11,13 @@ "check-types": "next typegen && tsc --noEmit" }, "dependencies": { + "@iconify/react": "^6.0.2", "@number-flow/react": "^0.5.14", "@pascal-app/core": "*", "@pascal-app/editor": "*", "@pascal-app/mcp": "*", "@pascal-app/viewer": "*", + "@radix-ui/react-tooltip": "^1.2.8", "@react-three/drei": "^10.7.7", "@react-three/fiber": "^9.5.0", "@tailwindcss/postcss": "^4.2.1", diff --git a/bun.lock b/bun.lock index 8a11d1a77..64ed55c62 100644 --- a/bun.lock +++ b/bun.lock @@ -26,11 +26,13 @@ "name": "editor", "version": "0.1.0", "dependencies": { + "@iconify/react": "^6.0.2", "@number-flow/react": "^0.5.14", "@pascal-app/core": "*", "@pascal-app/editor": "*", "@pascal-app/mcp": "*", "@pascal-app/viewer": "*", + "@radix-ui/react-tooltip": "^1.2.8", "@react-three/drei": "^10.7.7", "@react-three/fiber": "^9.5.0", "@tailwindcss/postcss": "^4.2.1", @@ -205,7 +207,6 @@ "name": "@pascal-app/viewer", "version": "0.8.0", "dependencies": { - "polygon-clipping": "^0.15.7", "three-bvh-csg": "^0.0.18", "three-mesh-bvh": "^0.9.8", "zustand": "^5", @@ -1260,8 +1261,6 @@ "pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="], - "polygon-clipping": ["polygon-clipping@0.15.7", "", { "dependencies": { "robust-predicates": "^3.0.2", "splaytree": "^3.1.0" } }, "sha512-nhfdr83ECBg6xtqOAJab1tbksbBAOMUltN60bU+llHVOL0e5Onm1WpAXXWXVB39L8AJFssoIhEVuy/S90MmotA=="], - "possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="], "postcss": ["postcss@8.5.14", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg=="], @@ -1322,8 +1321,6 @@ "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], - "robust-predicates": ["robust-predicates@3.0.3", "", {}, "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA=="], - "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], @@ -1374,8 +1371,6 @@ "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], - "splaytree": ["splaytree@3.2.3", "", {}, "sha512-7OXrNWzy6CK+r7Ch9OLPBDTKfB6XlWHjX4P0RU5B3IgFuWPeYN0XtRtlexGRjgbQxpfaUve6jTAwBGWuGntz/w=="], - "stats-gl": ["stats-gl@2.4.2", "", { "dependencies": { "@types/three": "*", "three": "^0.170.0" } }, "sha512-g5O9B0hm9CvnM36+v7SFl39T7hmAlv541tU81ME8YeSb3i1CIP5/QdDeSB3A0la0bKNHpxpwxOVRo2wFTYEosQ=="], "stats.js": ["stats.js@0.17.0", "", {}, "sha512-hNKz8phvYLPEcRkeG1rsGmV5ChMjKDAWU7/OJJdDErPBNChQXxCo3WZurGpnWc6gZhAzEPFad1aVgyOANH1sMw=="], diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index d3875421f..bca40c4dc 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -45,6 +45,8 @@ export { getRenderableSlabPolygon } from './lib/slab-polygon' export { detectSpacesForLevel, initSpaceDetectionSync, + planAutoSlabsForLevel, + type AutoSlabSyncPlan, type Space, wallTouchesOthers, } from './lib/space-detection' @@ -107,6 +109,15 @@ export { type WallMiterBoundaryPoints, type WallMiterData, } from './systems/wall/wall-mitering' +export { + constrainWallMoveDeltaToAxis, + getPerpendicularWallMoveAxis, + planWallMoveJunctions, + type WallMoveBridgePlan, + type WallMoveAxis, + type WallMoveJunctionPlan, + type WallPlanPoint, +} from './systems/wall/wall-move' export type { SceneGraph } from './utils/clone-scene-graph' export { cloneLevelSubtree, cloneSceneGraph, forkSceneGraph } from './utils/clone-scene-graph' export { isObject } from './utils/types' diff --git a/packages/core/src/lib/space-detection.ts b/packages/core/src/lib/space-detection.ts index f9fda76e0..94a6e47b7 100644 --- a/packages/core/src/lib/space-detection.ts +++ b/packages/core/src/lib/space-detection.ts @@ -41,6 +41,12 @@ type DetectedRoom = { bbox: ReturnType } +export type AutoSlabSyncPlan = { + create: SlabNodeType[] + update: Array<{ id: SlabNodeType['id']; data: Partial }> + delete: Array +} + const DEFAULT_AUTO_SLAB_ELEVATION = 0.05 const DEFAULT_AUTO_CEILING_HEIGHT = 2.5 const ROOM_CURVE_TOLERANCE = 0.04 @@ -488,12 +494,10 @@ function buildSpace(levelId: string, polygon: Point2D[]): Space { } } -function syncAutoSlabsForLevel( - levelId: string, +export function planAutoSlabsForLevel( roomPolygons: Point2D[][], existingSlabs: SlabNodeType[], - sceneStore: any, -) { +): AutoSlabSyncPlan { const manualSlabs = existingSlabs.filter((slab) => !slab.autoFromWalls) const manualSignatures = new Set( manualSlabs.map((slab) => polygonSignature(slab.polygon.map(pointFromTuple))), @@ -618,16 +622,31 @@ function syncAutoSlabsForLevel( ) } - if (slabsToDelete.length > 0) { - sceneStore.getState().deleteNodes(slabsToDelete) + return { + create: slabsToCreate, + update: slabsToUpdate, + delete: slabsToDelete, + } +} + +function syncAutoSlabsForLevel( + levelId: string, + roomPolygons: Point2D[][], + existingSlabs: SlabNodeType[], + sceneStore: any, +) { + const plan = planAutoSlabsForLevel(roomPolygons, existingSlabs) + + if (plan.delete.length > 0) { + sceneStore.getState().deleteNodes(plan.delete) } - if (slabsToUpdate.length > 0) { - sceneStore.getState().updateNodes(slabsToUpdate) + if (plan.update.length > 0) { + sceneStore.getState().updateNodes(plan.update) } - if (slabsToCreate.length > 0) { - sceneStore.getState().createNodes(slabsToCreate.map((node) => ({ node, parentId: levelId }))) + if (plan.create.length > 0) { + sceneStore.getState().createNodes(plan.create.map((node) => ({ node, parentId: levelId }))) } } diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts index 0a16d9517..b088079a5 100644 --- a/packages/core/src/schema/index.ts +++ b/packages/core/src/schema/index.ts @@ -39,6 +39,7 @@ export { ColumnShaftDetail, ColumnShaftProfile, ColumnStyle, + ColumnSupportStyle, } from './nodes/column' export { DoorNode, DoorSegment } from './nodes/door' export { FenceBaseStyle, FenceNode, FenceStyle } from './nodes/fence' diff --git a/packages/core/src/schema/nodes/column.ts b/packages/core/src/schema/nodes/column.ts index a7fa83a8e..78a521264 100644 --- a/packages/core/src/schema/nodes/column.ts +++ b/packages/core/src/schema/nodes/column.ts @@ -56,6 +56,20 @@ export const ColumnRingPlacement = z.enum(['ends', 'even', 'top', 'bottom']) export const ColumnCarvingPlacement = z.enum(['shaft', 'base', 'capital', 'all']) +export const ColumnSupportStyle = z.enum([ + 'vertical', + 'a-frame', + 'y-frame', + 'v-frame', + 'x-brace', + 'k-brace', + 'single-strut', + 'tripod', + 'trestle', + 'portal-frame', + 'box-frame', +]) + export type ColumnStyle = z.infer export type ColumnCrossSection = z.infer export type ColumnShaftProfile = z.infer @@ -65,6 +79,7 @@ export type ColumnBaseStyle = z.infer export type ColumnCapitalStyle = z.infer export type ColumnRingPlacement = z.infer export type ColumnCarvingPlacement = z.infer +export type ColumnSupportStyle = z.infer export const ColumnNode = BaseNode.extend({ id: objectId('column'), @@ -73,7 +88,7 @@ export const ColumnNode = BaseNode.extend({ rotation: z.number().default(0), style: ColumnStyle.default('plain'), crossSection: ColumnCrossSection.default('round'), - height: z.number().positive().default(2.8), + height: z.number().positive().default(2.5), radius: z.number().positive().default(0.22), width: z.number().positive().default(0.44), depth: z.number().positive().default(0.44), @@ -136,6 +151,12 @@ export const ColumnNode = BaseNode.extend({ lowerBandCarvingLevel: z.number().int().min(0).max(4).default(0), dentilCount: z.number().int().min(0).max(48).default(0), beadCount: z.number().int().min(0).max(64).default(0), + supportStyle: ColumnSupportStyle.default('vertical'), + braceWidth: z.number().positive().default(0.16), + braceDepth: z.number().positive().default(0.16), + braceBottomSpread: z.number().min(0.2).default(1.2), + braceTopSpread: z.number().min(0).default(0.12), + bracePlateEnabled: z.boolean().default(true), material: MaterialSchema.optional(), materialPreset: z.string().optional(), }).describe(dedent` @@ -150,6 +171,7 @@ export const ColumnNode = BaseNode.extend({ - baseStyle/capitalStyle: procedural base and top treatment with tier/detail controls - baseHeight/capitalHeight: bottom and top block proportions - ring/flute/spiral/panel/lathe/carving fields: procedural detail controls + - supportStyle/brace fields: vertical column or procedural support assembly `) export const COLUMN_PRESETS = { @@ -157,7 +179,7 @@ export const COLUMN_PRESETS = { label: 'Straight Round', style: 'plain', crossSection: 'round', - height: 2.9, + height: 2.5, radius: 0.22, width: 0.44, depth: 0.44, @@ -207,7 +229,7 @@ export const COLUMN_PRESETS = { label: 'Square Block', style: 'faceted', crossSection: 'square', - height: 2.9, + height: 2.5, radius: 0.24, width: 0.48, depth: 0.48, @@ -257,7 +279,7 @@ export const COLUMN_PRESETS = { label: 'Tapered Round', style: 'plain', crossSection: 'round', - height: 3, + height: 2.5, radius: 0.23, width: 0.46, depth: 0.46, @@ -307,7 +329,7 @@ export const COLUMN_PRESETS = { label: 'Soft Bulged', style: 'plain', crossSection: 'round', - height: 2.9, + height: 2.5, radius: 0.22, width: 0.44, depth: 0.44, @@ -357,7 +379,7 @@ export const COLUMN_PRESETS = { label: 'Hourglass', style: 'plain', crossSection: 'round', - height: 2.9, + height: 2.5, radius: 0.22, width: 0.44, depth: 0.44, @@ -403,6 +425,566 @@ export const COLUMN_PRESETS = { dentilCount: 0, beadCount: 0, }, + aFrameSupport: { + label: 'A-Frame Support', + supportStyle: 'a-frame', + style: 'faceted', + crossSection: 'rectangular', + height: 2.5, + radius: 0.08, + width: 0.16, + depth: 0.16, + edgeSoftness: 0.012, + baseHeight: 0, + capitalHeight: 0, + shaftProfile: 'straight', + shaftTaper: 0, + shaftBulge: 0, + shaftStartScale: 1, + shaftEndScale: 1, + shaftSegmentCount: 1, + shaftTwistStep: 0, + shaftCornerRadius: 0.012, + shaftDetail: 'none', + baseStyle: 'none', + baseWidthScale: 1, + baseDepthScale: 1, + baseTierCount: 1, + baseStepSpread: 0.34, + basePlinthHeightRatio: 0.44, + baseRoundBandScale: 0.92, + baseNeckScale: 0.72, + baseRoundBandCount: 0, + baseRibCount: 0, + baseCarvingLevel: 0, + capitalStyle: 'none', + capitalWidthScale: 1, + capitalDepthScale: 1, + capitalTierCount: 1, + capitalStepSpread: 0.34, + capitalBandCount: 0, + capitalCarvingLevel: 0, + ringCount: 0, + ringSpread: 0.16, + fluteCount: 0, + spiralTwist: 0, + spiralRibCount: 0, + panelCount: 0, + latheRingCount: 0, + carvingLevel: 0, + lowerBandEnabled: false, + dentilCount: 0, + beadCount: 0, + braceWidth: 0.16, + braceDepth: 0.16, + braceBottomSpread: 1.2, + braceTopSpread: 0.06, + bracePlateEnabled: false, + }, + yFrameSupport: { + label: 'Y Support', + supportStyle: 'y-frame', + style: 'faceted', + crossSection: 'rectangular', + height: 2.5, + radius: 0.08, + width: 0.16, + depth: 0.16, + edgeSoftness: 0.012, + baseHeight: 0, + capitalHeight: 0, + shaftProfile: 'straight', + shaftTaper: 0, + shaftBulge: 0, + shaftStartScale: 1, + shaftEndScale: 1, + shaftSegmentCount: 1, + shaftTwistStep: 0, + shaftCornerRadius: 0.012, + shaftDetail: 'none', + baseStyle: 'none', + baseWidthScale: 1, + baseDepthScale: 1, + baseTierCount: 1, + baseStepSpread: 0.34, + basePlinthHeightRatio: 0.44, + baseRoundBandScale: 0.92, + baseNeckScale: 0.72, + baseRoundBandCount: 0, + baseRibCount: 0, + baseCarvingLevel: 0, + capitalStyle: 'none', + capitalWidthScale: 1, + capitalDepthScale: 1, + capitalTierCount: 1, + capitalStepSpread: 0.34, + capitalBandCount: 0, + capitalCarvingLevel: 0, + ringCount: 0, + ringSpread: 0.16, + fluteCount: 0, + spiralTwist: 0, + spiralRibCount: 0, + panelCount: 0, + latheRingCount: 0, + carvingLevel: 0, + lowerBandEnabled: false, + dentilCount: 0, + beadCount: 0, + braceWidth: 0.16, + braceDepth: 0.16, + braceBottomSpread: 0.2, + braceTopSpread: 1, + bracePlateEnabled: false, + }, + vFrameSupport: { + label: 'V Support', + supportStyle: 'v-frame', + style: 'faceted', + crossSection: 'rectangular', + height: 2.5, + radius: 0.08, + width: 0.16, + depth: 0.16, + edgeSoftness: 0.012, + baseHeight: 0, + capitalHeight: 0, + shaftProfile: 'straight', + shaftTaper: 0, + shaftBulge: 0, + shaftStartScale: 1, + shaftEndScale: 1, + shaftSegmentCount: 1, + shaftTwistStep: 0, + shaftCornerRadius: 0.012, + shaftDetail: 'none', + baseStyle: 'none', + baseWidthScale: 1, + baseDepthScale: 1, + baseTierCount: 1, + baseStepSpread: 0.34, + basePlinthHeightRatio: 0.44, + baseRoundBandScale: 0.92, + baseNeckScale: 0.72, + baseRoundBandCount: 0, + baseRibCount: 0, + baseCarvingLevel: 0, + capitalStyle: 'none', + capitalWidthScale: 1, + capitalDepthScale: 1, + capitalTierCount: 1, + capitalStepSpread: 0.34, + capitalBandCount: 0, + capitalCarvingLevel: 0, + ringCount: 0, + ringSpread: 0.16, + fluteCount: 0, + spiralTwist: 0, + spiralRibCount: 0, + panelCount: 0, + latheRingCount: 0, + carvingLevel: 0, + lowerBandEnabled: false, + dentilCount: 0, + beadCount: 0, + braceWidth: 0.16, + braceDepth: 0.16, + braceBottomSpread: 0.2, + braceTopSpread: 1, + bracePlateEnabled: false, + }, + xBraceSupport: { + label: 'X Brace', + supportStyle: 'x-brace', + style: 'faceted', + crossSection: 'rectangular', + height: 2.5, + radius: 0.07, + width: 0.14, + depth: 0.14, + edgeSoftness: 0.01, + baseHeight: 0, + capitalHeight: 0, + shaftProfile: 'straight', + shaftTaper: 0, + shaftBulge: 0, + shaftStartScale: 1, + shaftEndScale: 1, + shaftSegmentCount: 1, + shaftTwistStep: 0, + shaftCornerRadius: 0.01, + shaftDetail: 'none', + baseStyle: 'none', + baseWidthScale: 1, + baseDepthScale: 1, + baseTierCount: 1, + baseStepSpread: 0.34, + basePlinthHeightRatio: 0.44, + baseRoundBandScale: 0.92, + baseNeckScale: 0.72, + baseRoundBandCount: 0, + baseRibCount: 0, + baseCarvingLevel: 0, + capitalStyle: 'none', + capitalWidthScale: 1, + capitalDepthScale: 1, + capitalTierCount: 1, + capitalStepSpread: 0.34, + capitalBandCount: 0, + capitalCarvingLevel: 0, + ringCount: 0, + ringSpread: 0.16, + fluteCount: 0, + spiralTwist: 0, + spiralRibCount: 0, + panelCount: 0, + latheRingCount: 0, + carvingLevel: 0, + lowerBandEnabled: false, + dentilCount: 0, + beadCount: 0, + braceWidth: 0.14, + braceDepth: 0.14, + braceBottomSpread: 1, + braceTopSpread: 1, + bracePlateEnabled: false, + }, + kBraceSupport: { + label: 'K Brace', + supportStyle: 'k-brace', + style: 'faceted', + crossSection: 'rectangular', + height: 2.5, + radius: 0.07, + width: 0.14, + depth: 0.14, + edgeSoftness: 0.01, + baseHeight: 0, + capitalHeight: 0, + shaftProfile: 'straight', + shaftTaper: 0, + shaftBulge: 0, + shaftStartScale: 1, + shaftEndScale: 1, + shaftSegmentCount: 1, + shaftTwistStep: 0, + shaftCornerRadius: 0.01, + shaftDetail: 'none', + baseStyle: 'none', + baseWidthScale: 1, + baseDepthScale: 1, + baseTierCount: 1, + baseStepSpread: 0.34, + basePlinthHeightRatio: 0.44, + baseRoundBandScale: 0.92, + baseNeckScale: 0.72, + baseRoundBandCount: 0, + baseRibCount: 0, + baseCarvingLevel: 0, + capitalStyle: 'none', + capitalWidthScale: 1, + capitalDepthScale: 1, + capitalTierCount: 1, + capitalStepSpread: 0.34, + capitalBandCount: 0, + capitalCarvingLevel: 0, + ringCount: 0, + ringSpread: 0.16, + fluteCount: 0, + spiralTwist: 0, + spiralRibCount: 0, + panelCount: 0, + latheRingCount: 0, + carvingLevel: 0, + lowerBandEnabled: false, + dentilCount: 0, + beadCount: 0, + braceWidth: 0.14, + braceDepth: 0.14, + braceBottomSpread: 1, + braceTopSpread: 1, + bracePlateEnabled: false, + }, + singleStrutSupport: { + label: 'Single Strut', + supportStyle: 'single-strut', + style: 'faceted', + crossSection: 'rectangular', + height: 2.5, + radius: 0.07, + width: 0.14, + depth: 0.14, + edgeSoftness: 0.01, + baseHeight: 0, + capitalHeight: 0, + shaftProfile: 'straight', + shaftTaper: 0, + shaftBulge: 0, + shaftStartScale: 1, + shaftEndScale: 1, + shaftSegmentCount: 1, + shaftTwistStep: 0, + shaftCornerRadius: 0.01, + shaftDetail: 'none', + baseStyle: 'none', + baseWidthScale: 1, + baseDepthScale: 1, + baseTierCount: 1, + baseStepSpread: 0.34, + basePlinthHeightRatio: 0.44, + baseRoundBandScale: 0.92, + baseNeckScale: 0.72, + baseRoundBandCount: 0, + baseRibCount: 0, + baseCarvingLevel: 0, + capitalStyle: 'none', + capitalWidthScale: 1, + capitalDepthScale: 1, + capitalTierCount: 1, + capitalStepSpread: 0.34, + capitalBandCount: 0, + capitalCarvingLevel: 0, + ringCount: 0, + ringSpread: 0.16, + fluteCount: 0, + spiralTwist: 0, + spiralRibCount: 0, + panelCount: 0, + latheRingCount: 0, + carvingLevel: 0, + lowerBandEnabled: false, + dentilCount: 0, + beadCount: 0, + braceWidth: 0.14, + braceDepth: 0.14, + braceBottomSpread: 1, + braceTopSpread: 1, + bracePlateEnabled: false, + }, + tripodSupport: { + label: 'Tripod Support', + supportStyle: 'tripod', + style: 'faceted', + crossSection: 'rectangular', + height: 2.5, + radius: 0.07, + width: 0.14, + depth: 0.14, + edgeSoftness: 0.01, + baseHeight: 0, + capitalHeight: 0, + shaftProfile: 'straight', + shaftTaper: 0, + shaftBulge: 0, + shaftStartScale: 1, + shaftEndScale: 1, + shaftSegmentCount: 1, + shaftTwistStep: 0, + shaftCornerRadius: 0.01, + shaftDetail: 'none', + baseStyle: 'none', + baseWidthScale: 1, + baseDepthScale: 1, + baseTierCount: 1, + baseStepSpread: 0.34, + basePlinthHeightRatio: 0.44, + baseRoundBandScale: 0.92, + baseNeckScale: 0.72, + baseRoundBandCount: 0, + baseRibCount: 0, + baseCarvingLevel: 0, + capitalStyle: 'none', + capitalWidthScale: 1, + capitalDepthScale: 1, + capitalTierCount: 1, + capitalStepSpread: 0.34, + capitalBandCount: 0, + capitalCarvingLevel: 0, + ringCount: 0, + ringSpread: 0.16, + fluteCount: 0, + spiralTwist: 0, + spiralRibCount: 0, + panelCount: 0, + latheRingCount: 0, + carvingLevel: 0, + lowerBandEnabled: false, + dentilCount: 0, + beadCount: 0, + braceWidth: 0.14, + braceDepth: 0.14, + braceBottomSpread: 1.1, + braceTopSpread: 1.1, + bracePlateEnabled: false, + }, + trestleSupport: { + label: 'Trestle Frame', + supportStyle: 'trestle', + style: 'faceted', + crossSection: 'rectangular', + height: 2.5, + radius: 0.07, + width: 0.14, + depth: 0.14, + edgeSoftness: 0.01, + baseHeight: 0, + capitalHeight: 0, + shaftProfile: 'straight', + shaftTaper: 0, + shaftBulge: 0, + shaftStartScale: 1, + shaftEndScale: 1, + shaftSegmentCount: 1, + shaftTwistStep: 0, + shaftCornerRadius: 0.01, + shaftDetail: 'none', + baseStyle: 'none', + baseWidthScale: 1, + baseDepthScale: 1, + baseTierCount: 1, + baseStepSpread: 0.34, + basePlinthHeightRatio: 0.44, + baseRoundBandScale: 0.92, + baseNeckScale: 0.72, + baseRoundBandCount: 0, + baseRibCount: 0, + baseCarvingLevel: 0, + capitalStyle: 'none', + capitalWidthScale: 1, + capitalDepthScale: 1, + capitalTierCount: 1, + capitalStepSpread: 0.34, + capitalBandCount: 0, + capitalCarvingLevel: 0, + ringCount: 0, + ringSpread: 0.16, + fluteCount: 0, + spiralTwist: 0, + spiralRibCount: 0, + panelCount: 0, + latheRingCount: 0, + carvingLevel: 0, + lowerBandEnabled: false, + dentilCount: 0, + beadCount: 0, + braceWidth: 0.14, + braceDepth: 0.14, + braceBottomSpread: 1.2, + braceTopSpread: 1, + bracePlateEnabled: false, + }, + portalFrameSupport: { + label: 'Portal Frame', + supportStyle: 'portal-frame', + style: 'faceted', + crossSection: 'rectangular', + height: 2.5, + radius: 0.08, + width: 0.16, + depth: 0.16, + edgeSoftness: 0.012, + baseHeight: 0, + capitalHeight: 0, + shaftProfile: 'straight', + shaftTaper: 0, + shaftBulge: 0, + shaftStartScale: 1, + shaftEndScale: 1, + shaftSegmentCount: 1, + shaftTwistStep: 0, + shaftCornerRadius: 0.012, + shaftDetail: 'none', + baseStyle: 'none', + baseWidthScale: 1, + baseDepthScale: 1, + baseTierCount: 1, + baseStepSpread: 0.34, + basePlinthHeightRatio: 0.44, + baseRoundBandScale: 0.92, + baseNeckScale: 0.72, + baseRoundBandCount: 0, + baseRibCount: 0, + baseCarvingLevel: 0, + capitalStyle: 'none', + capitalWidthScale: 1, + capitalDepthScale: 1, + capitalTierCount: 1, + capitalStepSpread: 0.34, + capitalBandCount: 0, + capitalCarvingLevel: 0, + ringCount: 0, + ringSpread: 0.16, + fluteCount: 0, + spiralTwist: 0, + spiralRibCount: 0, + panelCount: 0, + latheRingCount: 0, + carvingLevel: 0, + lowerBandEnabled: false, + dentilCount: 0, + beadCount: 0, + braceWidth: 0.16, + braceDepth: 0.16, + braceBottomSpread: 1.4, + braceTopSpread: 0.2, + bracePlateEnabled: false, + }, + boxFrameSupport: { + label: 'Box Frame', + supportStyle: 'box-frame', + style: 'faceted', + crossSection: 'rectangular', + height: 2.5, + radius: 0.07, + width: 0.14, + depth: 0.14, + edgeSoftness: 0.01, + baseHeight: 0, + capitalHeight: 0, + shaftProfile: 'straight', + shaftTaper: 0, + shaftBulge: 0, + shaftStartScale: 1, + shaftEndScale: 1, + shaftSegmentCount: 1, + shaftTwistStep: 0, + shaftCornerRadius: 0.01, + shaftDetail: 'none', + baseStyle: 'none', + baseWidthScale: 1, + baseDepthScale: 1, + baseTierCount: 1, + baseStepSpread: 0.34, + basePlinthHeightRatio: 0.44, + baseRoundBandScale: 0.92, + baseNeckScale: 0.72, + baseRoundBandCount: 0, + baseRibCount: 0, + baseCarvingLevel: 0, + capitalStyle: 'none', + capitalWidthScale: 1, + capitalDepthScale: 1, + capitalTierCount: 1, + capitalStepSpread: 0.34, + capitalBandCount: 0, + capitalCarvingLevel: 0, + ringCount: 0, + ringSpread: 0.16, + fluteCount: 0, + spiralTwist: 0, + spiralRibCount: 0, + panelCount: 0, + latheRingCount: 0, + carvingLevel: 0, + lowerBandEnabled: false, + dentilCount: 0, + beadCount: 0, + braceWidth: 0.14, + braceDepth: 0.14, + braceBottomSpread: 1.4, + braceTopSpread: 1, + bracePlateEnabled: false, + }, } as const satisfies Record>> export type ColumnPresetId = keyof typeof COLUMN_PRESETS diff --git a/packages/core/src/schema/nodes/fence.ts b/packages/core/src/schema/nodes/fence.ts index d7726084f..408757173 100644 --- a/packages/core/src/schema/nodes/fence.ts +++ b/packages/core/src/schema/nodes/fence.ts @@ -23,6 +23,7 @@ export const FenceNode = BaseNode.extend({ groundClearance: z.number().default(0), edgeInset: z.number().default(0.015), baseStyle: FenceBaseStyle.default('grounded'), + showInfill: z.boolean().default(true), color: z.string().default('#ffffff'), style: FenceStyle.default('slat'), }).describe( @@ -33,6 +34,7 @@ export const FenceNode = BaseNode.extend({ - curveOffset: midpoint sagitta offset used to bend the fence into an arc - baseHeight/postSpacing/postSize/topRailHeight: exact geometric controls from the plan3D fence model - groundClearance/edgeInset/baseStyle: fence support and inset configuration + - showInfill: whether to draw intermediate posts/slats between end posts - color/style: visual appearance options `, ) diff --git a/packages/core/src/store/actions/node-actions.ts b/packages/core/src/store/actions/node-actions.ts index 81f587b7c..401385bb7 100644 --- a/packages/core/src/store/actions/node-actions.ts +++ b/packages/core/src/store/actions/node-actions.ts @@ -9,6 +9,9 @@ import type { CollectionId } from '../../schema/collections' import type { SceneState } from '../use-scene' type AnyContainerNode = AnyNode & { children: string[] } +type NodeCreateOp = { node: AnyNode; parentId?: AnyNodeId } +type NodeUpdateOp = { id: AnyNodeId; data: Partial } +type NodeDeleteOp = AnyNodeId type WallAttachmentUpdate = { id: AnyNodeId; data: Partial } type WallMergePlan = { primaryWallId: AnyNodeId @@ -232,7 +235,7 @@ function buildWallMergePlans( export const createNodesAction = ( set: (fn: (state: SceneState) => Partial) => void, get: () => SceneState, - ops: { node: AnyNode; parentId?: AnyNodeId }[], + ops: NodeCreateOp[], ) => { if (get().readOnly) return set((state) => { @@ -281,6 +284,143 @@ export const createNodesAction = ( }) } +export const applyNodeChangesAction = ( + set: (fn: (state: SceneState) => Partial) => 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() + const parentsToMarkDirty = new Set() + + 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() + const collectDelete = (id: AnyNodeId) => { + if (allIdsToDelete.has(id)) return + allIdsToDelete.add(id) + const node = nextNodes[id] + if (node && 'children' in node && Array.isArray(node.children)) { + for (const childId of node.children) { + collectDelete(childId as AnyNodeId) + } + } + } + + for (const id of deleteOps) { + collectDelete(id) + } + + for (const id of allIdsToDelete) { + const node = nextNodes[id] + if (!node) continue + + const parentId = node.parentId as AnyNodeId | null + if (parentId && nextNodes[parentId] && !allIdsToDelete.has(parentId)) { + const parent = nextNodes[parentId] as AnyContainerNode + if (parent.children) { + nextNodes[parent.id] = { + ...parent, + children: parent.children.filter((childId) => childId !== id), + } as AnyNode + parentsToMarkDirty.add(parent.id) + } + } + + resolvedRootIds = resolvedRootIds.filter((rootId) => rootId !== id) + + if ('collectionIds' in node && node.collectionIds) { + for (const collectionId of node.collectionIds as CollectionId[]) { + const collection = nextCollections[collectionId] + if (collection) { + nextCollections[collectionId] = { + ...collection, + nodeIds: collection.nodeIds.filter((nodeId) => nodeId !== id), + } + } + } + } + + delete nextNodes[id] + } + + return { nodes: nextNodes, rootNodeIds: resolvedRootIds, collections: nextCollections } + }) + + nodesToMarkDirty.forEach((id) => get().markDirty(id)) + parentsToMarkDirty.forEach((id) => { + get().markDirty(id) + const parent = get().nodes[id] + if (parent && 'children' in parent && Array.isArray(parent.children)) { + for (const childId of parent.children) { + get().markDirty(childId as AnyNodeId) + } + } + }) +} + export const updateNodesAction = ( set: (fn: (state: SceneState) => Partial) => void, get: () => SceneState, diff --git a/packages/core/src/store/use-scene.ts b/packages/core/src/store/use-scene.ts index 4a0cf5c53..48615bbf1 100644 --- a/packages/core/src/store/use-scene.ts +++ b/packages/core/src/store/use-scene.ts @@ -438,6 +438,11 @@ export type SceneState = { createNode: (node: AnyNode, parentId?: AnyNodeId) => void createNodes: (ops: { node: AnyNode; parentId?: AnyNodeId }[]) => void + applyNodeChanges: (changes: { + create?: { node: AnyNode; parentId?: AnyNodeId }[] + update?: { id: AnyNodeId; data: Partial }[] + delete?: AnyNodeId[] + }) => void updateNode: (id: AnyNodeId, data: Partial) => void updateNodes: (updates: { id: AnyNodeId; data: Partial }[]) => void @@ -586,6 +591,7 @@ const useScene: UseSceneStore = create()( createNodes: (ops) => nodeActions.createNodesAction(set, get, ops), createNode: (node, parentId) => nodeActions.createNodesAction(set, get, [{ node, parentId }]), + applyNodeChanges: (changes) => nodeActions.applyNodeChangesAction(set, get, changes), updateNodes: (updates) => nodeActions.updateNodesAction(set, get, updates), updateNode: (id, data) => nodeActions.updateNodesAction(set, get, [{ id, data }]), diff --git a/packages/core/src/systems/wall/wall-move.ts b/packages/core/src/systems/wall/wall-move.ts new file mode 100644 index 000000000..e9b6595c4 --- /dev/null +++ b/packages/core/src/systems/wall/wall-move.ts @@ -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> = { + wall: TWall + originalPoint: WallPlanPoint + movedEndpoint: WallMoveEndpoint +} + +export type WallMoveLinkedWallTargetPlan< + TWall extends Pick, +> = { + wall: TWall + originalPoint: WallPlanPoint + targetPoint: WallPlanPoint +} + +export type WallMoveJunctionPlan> = { + linkedWallsToMove: TWall[] + linkedWallTargetPlans: Array> + bridgePlans: Array> + 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, point: WallPlanPoint) { + return pointsEqual(wall.start, point) || pointsEqual(wall.end, point) +} + +function otherWallEndpoint(wall: Pick, point: WallPlanPoint) { + return pointsEqual(wall.start, point) ? wall.end : wall.start +} + +type MoveWallRelation = 'same-direction' | 'opposite-direction' | 'off-axis' | 'stationary' +type RelatedWallEntry> = { + wall: TWall + relation: MoveWallRelation +} + +function wallLengthFromPoint(wall: Pick, point: WallPlanPoint) { + const freeEndpoint = otherWallEndpoint(wall, point) + return Math.hypot(freeEndpoint[0] - point[0], freeEndpoint[1] - point[1]) +} + +function getMoveWallRelation( + wall: Pick, + 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>( + linkedWalls: TWall[], + originalStart: WallPlanPoint, + originalEnd: WallPlanPoint, + nextStart: WallPlanPoint, + nextEnd: WallPlanPoint, +): WallMoveJunctionPlan { + const linkedWallsToMove = new Map() + const linkedWallTargetPlans = new Map>() + const bridgePlans = new Map>() + const wallsToDelete = new Map() + + const addStandardEndpointPlan = ( + endpoint: WallMoveEndpoint, + point: WallPlanPoint, + nextPoint: WallPlanPoint, + relatedWalls: Array>, + 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()), + } +} diff --git a/packages/editor/src/components/editor-2d/floorplan-action-menu-layer.tsx b/packages/editor/src/components/editor-2d/floorplan-action-menu-layer.tsx index fefaf949a..6ad90876b 100644 --- a/packages/editor/src/components/editor-2d/floorplan-action-menu-layer.tsx +++ b/packages/editor/src/components/editor-2d/floorplan-action-menu-layer.tsx @@ -16,6 +16,7 @@ export type FloorplanActionMenuEntry = { onDelete: FloorplanActionMenuHandler onMove: FloorplanActionMenuHandler onAddHole?: FloorplanActionMenuHandler + onCurve?: FloorplanActionMenuHandler onDuplicate?: FloorplanActionMenuHandler } @@ -81,6 +82,7 @@ export const FloorplanActionMenuLayer = memo(function FloorplanActionMenuLayer({ > e.stopPropagation()} onPointerUp={(e) => e.stopPropagation()} /> diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index a64b8800a..d4f9ff2c6 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -39,6 +39,7 @@ import { sceneRegistry, useLiveTransforms, useScene, + WallNode as WallNodeSchema, type WallNode, WindowNode, ZoneNode as ZoneNodeSchema, @@ -625,6 +626,12 @@ type FloorplanSpawnEntry = { rotation: number } +type FloorplanColumnEntry = { + column: ColumnNode + points: string + polygon: Point2D[] +} + type ReferenceFloorData = { ceilingPolygons: CeilingPolygonEntry[] columnEntries: ReferenceFloorColumnEntry[] @@ -1768,6 +1775,56 @@ function getRotatedRectanglePolygon( function getColumnPlanFootprint(column: ColumnNode): Point2D[] { const center = { x: column.position[0], y: column.position[2] } + + if ( + column.supportStyle === 'a-frame' || + column.supportStyle === 'y-frame' || + column.supportStyle === 'v-frame' || + column.supportStyle === 'x-brace' || + column.supportStyle === 'k-brace' || + column.supportStyle === 'single-strut' || + column.supportStyle === 'tripod' || + column.supportStyle === 'trestle' || + column.supportStyle === 'portal-frame' || + column.supportStyle === 'box-frame' + ) { + const width = Math.max( + column.supportStyle === 'a-frame' || + column.supportStyle === 'x-brace' || + column.supportStyle === 'k-brace' || + column.supportStyle === 'single-strut' || + column.supportStyle === 'tripod' || + column.supportStyle === 'trestle' || + column.supportStyle === 'portal-frame' || + column.supportStyle === 'box-frame' + ? (column.braceBottomSpread ?? 1.2) + : 0, + column.braceTopSpread ?? + (column.supportStyle === 'y-frame' || + column.supportStyle === 'v-frame' || + column.supportStyle === 'x-brace' || + column.supportStyle === 'k-brace' || + column.supportStyle === 'single-strut' || + column.supportStyle === 'tripod' || + column.supportStyle === 'trestle' || + column.supportStyle === 'portal-frame' || + column.supportStyle === 'box-frame' + ? 1 + : 0), + (column.braceWidth ?? column.width) * 2, + ) + const depth = Math.max( + column.supportStyle === 'tripod' || + column.supportStyle === 'trestle' || + column.supportStyle === 'box-frame' + ? (column.braceTopSpread ?? 1) + : 0, + column.braceDepth ?? column.depth, + 0.08, + ) + return getRotatedRectanglePolygon(center, width, depth, column.rotation) + } + const shaftWidth = column.crossSection === 'round' || column.crossSection === 'octagonal' || @@ -5742,6 +5799,12 @@ const FloorplanFenceLayer = memo(function FloorplanFenceLayer({ const fenceGlowOpacity = isDeleteHovered ? 0.18 : isActive ? 0.22 : isHovered ? 0.14 : 0 const fenceUnderlayWidth = isActive ? '6.5' : isHovered ? '6' : '5.2' const fenceStrokeWidth = isActive ? '2.6' : isHovered ? '2.35' : '2.05' + const showFenceInfill = fence.showInfill ?? true + const visibleMarkerFrames = showFenceInfill + ? markerFrames + : markerFrames.filter( + (_, markerIndex) => markerIndex === 0 || markerIndex === markerFrames.length - 1, + ) const privacyMarkerWidth = clamp(fence.postSize * 0.58, 0.038, 0.068) const privacyMarkerHeight = clamp( Math.max(fence.baseHeight * 0.5, fence.postSize * 1.4), @@ -5792,7 +5855,7 @@ const FloorplanFenceLayer = memo(function FloorplanFenceLayer({ strokeWidth={fenceStrokeWidth} vectorEffect="non-scaling-stroke" /> - {markerFrames.map(({ angleDeg, point }, markerIndex) => { + {visibleMarkerFrames.map(({ angleDeg, point }, markerIndex) => { const svgPoint = toSvgPoint(point) if (fence.style === 'privacy') { @@ -7492,6 +7555,7 @@ export function FloorplanPanel() { const setPhase = useEditor((state) => state.setPhase) const setMovingFenceEndpoint = useEditor((state) => state.setMovingFenceEndpoint) const setMovingNode = useEditor((state) => state.setMovingNode) + const setCurvingWall = useEditor((state) => state.setCurvingWall) const movingFenceEndpoint = useEditor((state) => state.movingFenceEndpoint) const structureLayer = useEditor((state) => state.structureLayer) const setStructureLayer = useEditor((state) => state.setStructureLayer) @@ -8140,6 +8204,28 @@ export function FloorplanPanel() { : entry, ) }, [zoneBoundaryDraft, zonePolygons]) + const floorplanColumnEntries = useMemo( + () => + levelDescendantNodes.flatMap((node) => { + if (!(node.type === 'column' && node.visible !== false)) { + return [] + } + + const polygon = getColumnPlanFootprint(node) + if (polygon.length < 3) { + return [] + } + + return [ + { + column: node, + points: formatPolygonPoints(polygon), + polygon, + }, + ] + }), + [levelDescendantNodes], + ) const levelDescendantNodeById = useMemo( () => new Map(levelDescendantNodes.map((node) => [node.id, node] as const)), [levelDescendantNodes], @@ -9240,6 +9326,7 @@ export function FloorplanPanel() { selectedWallEntry, wallCurveDraft, ]) + const canCurveSelectedWall = wallCurveHandles.length > 0 const slabVertexHandles = useMemo(() => { if (!shouldShowSlabBoundaryHandles) { return [] @@ -12967,6 +13054,7 @@ export function FloorplanPanel() { ) const { getFloorplanHitIdAtPoint, getFloorplanSelectionIdsInBounds } = useFloorplanHitTesting({ ceilingPolygons: displayCeilingPolygons, + columnPolygons: floorplanColumnEntries, displaySlabPolygons, displayWallPolygons, floorplanItemEntries, @@ -13981,6 +14069,57 @@ export function FloorplanPanel() { }, [selectedWallEntry, setMovingNode, setSelection], ) + const duplicateSelectedWall = useCallback(() => { + const wall = selectedWallEntry?.wall + if (!wall?.parentId) { + return + } + + sfxEmitter.emit('sfx:item-pick') + + const cloned = structuredClone(wall) as Record + 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) => { + event.stopPropagation() + duplicateSelectedWall() + }, + [duplicateSelectedWall], + ) + const handleSelectedWallCurve = useCallback( + (event: ReactMouseEvent) => { + event.stopPropagation() + + const wall = selectedWallEntry?.wall + if (!(wall && canCurveSelectedWall)) { + return + } + + sfxEmitter.emit('sfx:item-pick') + setCurvingWall(wall) + setSelection({ selectedIds: [] }) + }, + [canCurveSelectedWall, selectedWallEntry, setCurvingWall, setSelection], + ) const handleSelectedWallDelete = useCallback( (event: ReactMouseEvent) => { event.stopPropagation() @@ -16020,9 +16159,17 @@ export function FloorplanPanel() { site, ]) const hasDuplicatableFloorplanSelection = Boolean( - selectedItemEntry || selectedOpeningEntry || selectedStairEntry || selectedRoofEntry, + selectedItemEntry || + selectedOpeningEntry || + selectedStairEntry || + selectedRoofEntry || + selectedWallEntry, ) const handleDuplicateFloorplanSelection = useCallback(() => { + if (selectedWallEntry) { + duplicateSelectedWall() + return + } if (selectedOpeningEntry) { duplicateSelectedOpening() return @@ -16039,6 +16186,7 @@ export function FloorplanPanel() { duplicateSelectedRoof() } }, [ + duplicateSelectedWall, duplicateSelectedItem, duplicateSelectedOpening, duplicateSelectedRoof, @@ -16047,6 +16195,7 @@ export function FloorplanPanel() { selectedOpeningEntry, selectedRoofEntry, selectedStairEntry, + selectedWallEntry, ]) const activeDraftAnchorPoint = referenceScaleDraft?.start ?? @@ -16173,7 +16322,9 @@ export function FloorplanPanel() { }} wall={{ position: selectedWallActionMenuPosition, + onCurve: canCurveSelectedWall ? handleSelectedWallCurve : undefined, onDelete: handleSelectedWallDelete, + onDuplicate: handleSelectedWallDuplicate, onMove: handleSelectedWallMove, }} /> diff --git a/packages/editor/src/components/editor/index.tsx b/packages/editor/src/components/editor/index.tsx index 5e8930437..8515223cb 100644 --- a/packages/editor/src/components/editor/index.tsx +++ b/packages/editor/src/components/editor/index.tsx @@ -68,6 +68,7 @@ import { SiteEdgeLabels } from './site-edge-labels' import { SnapshotCaptureOverlay } from './snapshot-capture-overlay' import { type SnapshotCameraData, ThumbnailGenerator } from './thumbnail-generator' import { WallMeasurementLabel } from './wall-measurement-label' +import { WallMoveSideHandles } from './wall-move-side-handles' const CAMERA_CONTROLS_HINT_DISMISSED_STORAGE_KEY = 'editor-camera-controls-hint-dismissed:v1' const DELETE_CURSOR_BADGE_COLOR = '#ef4444' @@ -587,6 +588,7 @@ const ViewerSceneContent = memo(function ViewerSceneContent({ <> {!isFirstPersonMode && } {!(isVersionPreviewMode || isFirstPersonMode) && } + {!(isVersionPreviewMode || isFirstPersonMode) && } {!(isVersionPreviewMode || isFirstPersonMode) && } {!(isVersionPreviewMode || isFirstPersonMode) && } {!isFirstPersonMode && } diff --git a/packages/editor/src/components/editor/selection-manager.tsx b/packages/editor/src/components/editor/selection-manager.tsx index 780f4d44c..7e239f28e 100644 --- a/packages/editor/src/components/editor/selection-manager.tsx +++ b/packages/editor/src/components/editor/selection-manager.tsx @@ -1636,6 +1636,7 @@ const EditorOutlinerSync = () => { const previewSelectedIds = useViewer((s) => s.previewSelectedIds) const hoveredId = useViewer((s) => s.hoveredId) const outliner = useViewer((s) => s.outliner) + const nodes = useScene((s) => s.nodes) useEffect(() => { let idsToHighlight: string[] = [] @@ -1672,16 +1673,21 @@ const EditorOutlinerSync = () => { // 2. Sync with the imperative outliner arrays (mutate in place to keep references) outliner.selectedObjects.length = 0 for (const id of idsToHighlight) { + if (!nodes[id as AnyNodeId]) continue const obj = sceneRegistry.nodes.get(id) if (obj?.parent) outliner.selectedObjects.push(obj) } outliner.hoveredObjects.length = 0 if (hoveredId) { - const obj = sceneRegistry.nodes.get(hoveredId) - if (obj?.parent) outliner.hoveredObjects.push(obj) + if (!nodes[hoveredId as AnyNodeId]) { + useViewer.setState({ hoveredId: null }) + } else { + const obj = sceneRegistry.nodes.get(hoveredId) + if (obj?.parent) outliner.hoveredObjects.push(obj) + } } - }, [phase, previewSelectedIds, selection, hoveredId, outliner]) + }, [phase, previewSelectedIds, selection, hoveredId, outliner, nodes]) return null } diff --git a/packages/editor/src/components/editor/use-floorplan-hit-testing.ts b/packages/editor/src/components/editor/use-floorplan-hit-testing.ts index 397223054..09bc830cb 100644 --- a/packages/editor/src/components/editor/use-floorplan-hit-testing.ts +++ b/packages/editor/src/components/editor/use-floorplan-hit-testing.ts @@ -3,6 +3,7 @@ import type { AnyNode, CeilingNode, + ColumnNode, DoorNode, ItemNode, Point2D, @@ -46,6 +47,11 @@ type CeilingPolygonEntry = { holes: Point2D[][] } +type ColumnPolygonEntry = { + column: ColumnNode + polygon: Point2D[] +} + type FloorplanRoofEntry = { roof: RoofNode segments: Array<{ @@ -72,6 +78,7 @@ type FloorplanStairEntry = { type UseFloorplanHitTestingArgs = { ceilingPolygons: CeilingPolygonEntry[] + columnPolygons: ColumnPolygonEntry[] displaySlabPolygons: SlabPolygonEntry[] displayWallPolygons: WallPolygonEntry[] floorplanItemEntries: FloorplanItemEntry[] @@ -88,6 +95,7 @@ type UseFloorplanHitTestingArgs = { export function useFloorplanHitTesting({ ceilingPolygons, + columnPolygons, displaySlabPolygons, displayWallPolygons, floorplanItemEntries, @@ -117,11 +125,13 @@ export function useFloorplanHitTesting({ slabs: displaySlabPolygons, openingHitTolerance: floorplanOpeningHitTolerance, wallHitTolerance: floorplanWallHitTolerance, + columns: columnPolygons, getOpeningCenterLine, }) }, [ ceilingPolygons, + columnPolygons, displaySlabPolygons, displayWallPolygons, floorplanItemEntries, @@ -149,10 +159,12 @@ export function useFloorplanHitTesting({ openings: openingsPolygons, roofs: floorplanRoofEntries, slabs: displaySlabPolygons, + columns: columnPolygons, stairs: floorplanStairEntries, }), [ ceilingPolygons, + columnPolygons, displaySlabPolygons, displayWallPolygons, floorplanItemEntries, diff --git a/packages/editor/src/components/editor/wall-move-side-handles.tsx b/packages/editor/src/components/editor/wall-move-side-handles.tsx new file mode 100644 index 000000000..43bd9019d --- /dev/null +++ b/packages/editor/src/components/editor/wall-move-side-handles.tsx @@ -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 +} + +function WallMoveSideHandlesForWall({ wall }: { wall: WallNode }) { + const [levelObject, setLevelObject] = useState(() => + 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( + + {handles.map((handle) => ( + + ))} + , + 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) => { + 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 ( + + { + 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} + > + + + + + ) +} + +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]), + } +} diff --git a/packages/editor/src/components/tools/column/column-tool.tsx b/packages/editor/src/components/tools/column/column-tool.tsx index e9593d1a0..837b1729b 100644 --- a/packages/editor/src/components/tools/column/column-tool.tsx +++ b/packages/editor/src/components/tools/column/column-tool.tsx @@ -13,7 +13,6 @@ import { import { useEffect, useRef, useState } from 'react' import type { Group } from 'three' import { sfxEmitter } from '../../../lib/sfx-bus' -import useEditor from '../../../store/use-editor' import { CursorSphere } from '../shared/cursor-sphere' const COLUMN_ICON = ( @@ -70,8 +69,6 @@ export const ColumnTool: React.FC = ({ currentLevelId, onPlaced useScene.getState().createNode(column, currentLevelId) onPlaced?.(column.id) sfxEmitter.emit('sfx:structure-build') - useEditor.getState().setTool(null) - useEditor.getState().setMode('select') } emitter.on('grid:move', onGridMove) @@ -88,7 +85,7 @@ export const ColumnTool: React.FC = ({ currentLevelId, onPlaced return ( = [fence.start, fence.end] + for (const candidate of [fence.start, fence.end]) { + const candidateDistanceSquared = distanceSquared(point, candidate) + if ( + candidateDistanceSquared > cornerRadiusSquared || + candidateDistanceSquared >= bestCornerDistanceSquared + ) { + continue + } + + bestCornerTarget = candidate + bestCornerDistanceSquared = candidateDistanceSquared + } + if (isCurvedWall(fence)) { const sampleCount = Math.max(8, Math.ceil(getWallCurveLength(fence) / 0.3)) - for (let index = 0; index <= sampleCount; index += 1) { + for (let index = 1; index < sampleCount; index += 1) { const frame = getWallCurveFrameAt(fence, index / sampleCount) - candidates.push([frame.point.x, frame.point.y]) + 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 { - candidates.push(projectPointOntoSegment(point, fence)) - } - - for (const candidate of candidates) { + const candidate = projectPointOntoSegment(point, fence) if (!candidate) { continue } const candidateDistanceSquared = distanceSquared(point, candidate) if ( - candidateDistanceSquared > radiusSquared || - candidateDistanceSquared >= bestDistanceSquared + candidateDistanceSquared > spanRadiusSquared || + candidateDistanceSquared >= bestSpanDistanceSquared ) { continue } - bestTarget = candidate - bestDistanceSquared = candidateDistanceSquared + bestSpanTarget = candidate + bestSpanDistanceSquared = candidateDistanceSquared } } - return bestTarget + return bestCornerTarget ?? bestSpanTarget } export function snapFenceDraftPoint(args: { diff --git a/packages/editor/src/components/tools/fence/move-fence-endpoint-tool.tsx b/packages/editor/src/components/tools/fence/move-fence-endpoint-tool.tsx index 0658159bf..bc3f643d0 100644 --- a/packages/editor/src/components/tools/fence/move-fence-endpoint-tool.tsx +++ b/packages/editor/src/components/tools/fence/move-fence-endpoint-tool.tsx @@ -25,8 +25,13 @@ import { import { isWallLongEnough } from '../wall/wall-drafting' import { type FencePlanPoint, snapFenceDraftPoint } from './fence-drafting' +const LINKED_FENCE_ENDPOINT_EPSILON = 0.025 + function samePoint(a: FencePlanPoint, b: FencePlanPoint) { - return a[0] === b[0] && a[1] === b[1] + return ( + Math.abs(a[0] - b[0]) <= LINKED_FENCE_ENDPOINT_EPSILON && + Math.abs(a[1] - b[1]) <= LINKED_FENCE_ENDPOINT_EPSILON + ) } type SegmentLike = { @@ -114,10 +119,9 @@ type LinkedFenceSnapshot = { function getLinkedFenceSnapshots(args: { fenceId: FenceNode['id'] fenceParentId: string | null - originalStart: FencePlanPoint - originalEnd: FencePlanPoint + linkedPoint: FencePlanPoint }) { - const { fenceId, fenceParentId, originalStart, originalEnd } = args + const { fenceId, fenceParentId, linkedPoint } = args const { nodes } = useScene.getState() const snapshots: LinkedFenceSnapshot[] = [] @@ -130,14 +134,7 @@ function getLinkedFenceSnapshots(args: { continue } - if ( - !( - samePoint(node.start, originalStart) || - samePoint(node.start, originalEnd) || - samePoint(node.end, originalStart) || - samePoint(node.end, originalEnd) - ) - ) { + if (!samePoint(node.start, linkedPoint) && !samePoint(node.end, linkedPoint)) { continue } @@ -154,24 +151,14 @@ function getLinkedFenceSnapshots(args: { function getLinkedFenceUpdates( linkedFences: LinkedFenceSnapshot[], - originalStart: FencePlanPoint, - originalEnd: FencePlanPoint, - nextStart: FencePlanPoint, - nextEnd: FencePlanPoint, + linkedPoint: FencePlanPoint, + nextLinkedPoint: FencePlanPoint, ) { return linkedFences.map((fence) => ({ id: fence.id, curveOffset: fence.curveOffset, - start: samePoint(fence.start, originalStart) - ? nextStart - : samePoint(fence.start, originalEnd) - ? nextEnd - : fence.start, - end: samePoint(fence.end, originalStart) - ? nextStart - : samePoint(fence.end, originalEnd) - ? nextEnd - : fence.end, + start: samePoint(fence.start, linkedPoint) ? nextLinkedPoint : fence.start, + end: samePoint(fence.end, linkedPoint) ? nextLinkedPoint : fence.end, })) } @@ -183,6 +170,11 @@ export const MoveFenceEndpointTool: React.FC<{ target: MovingFenceEndpoint }> = const nodeIdRef = useRef(target.fence.id) const originalStartRef = useRef([...target.fence.start] as FencePlanPoint) const originalEndRef = useRef([...target.fence.end] as FencePlanPoint) + const originalMovingPointRef = useRef( + target.endpoint === 'start' + ? ([...target.fence.start] as FencePlanPoint) + : ([...target.fence.end] as FencePlanPoint), + ) const fixedPointRef = useRef( target.endpoint === 'start' ? ([...target.fence.end] as FencePlanPoint) @@ -192,8 +184,7 @@ export const MoveFenceEndpointTool: React.FC<{ target: MovingFenceEndpoint }> = getLinkedFenceSnapshots({ fenceId: target.fence.id, fenceParentId: target.fence.parentId ?? null, - originalStart: target.fence.start, - originalEnd: target.fence.end, + linkedPoint: target.endpoint === 'start' ? target.fence.start : target.fence.end, }), ) const previewRef = useRef<{ start: FencePlanPoint; end: FencePlanPoint } | null>(null) @@ -213,6 +204,7 @@ export const MoveFenceEndpointTool: React.FC<{ target: MovingFenceEndpoint }> = const nodeId = nodeIdRef.current const originalStart = originalStartRef.current const originalEnd = originalEndRef.current + const originalMovingPoint = originalMovingPointRef.current const fixedPoint = fixedPointRef.current const siblings = Object.values(useScene.getState().nodes) const levelWalls = siblings.filter( @@ -246,13 +238,7 @@ export const MoveFenceEndpointTool: React.FC<{ target: MovingFenceEndpoint }> = const nextEnd = target.endpoint === 'end' ? movingPoint : fixedPoint const linkedUpdates = detachLinkedFences ? [] - : getLinkedFenceUpdates( - linkedOriginalsRef.current, - originalStart, - originalEnd, - nextStart, - nextEnd, - ) + : getLinkedFenceUpdates(linkedOriginalsRef.current, originalMovingPoint, movingPoint) previewRef.current = { start: nextStart, end: nextEnd } setCursorLocalPos([movingPoint[0], 0, movingPoint[1]]) setAngleLabel( @@ -324,10 +310,8 @@ export const MoveFenceEndpointTool: React.FC<{ target: MovingFenceEndpoint }> = ? [] : getLinkedFenceUpdates( linkedOriginalsRef.current, - originalStart, - originalEnd, - preview.start, - preview.end, + originalMovingPoint, + target.endpoint === 'start' ? preview.start : preview.end, )), ]) pauseSceneHistory(useScene) diff --git a/packages/editor/src/components/tools/select/box-select-tool.tsx b/packages/editor/src/components/tools/select/box-select-tool.tsx index 1c270cd49..56362ab32 100644 --- a/packages/editor/src/components/tools/select/box-select-tool.tsx +++ b/packages/editor/src/components/tools/select/box-select-tool.tsx @@ -1,7 +1,10 @@ +import '../../../three-types' + import { Icon } from '@iconify/react' import { type AnyNodeId, type CeilingNode, + type ColumnNode, emitter, type GridEvent, type ItemNode, @@ -13,6 +16,7 @@ import { type ZoneNode, } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' +import type { ThreeElements } from '@react-three/fiber' import { useThree } from '@react-three/fiber' import { useEffect, useRef } from 'react' import { @@ -34,6 +38,12 @@ import { sfxEmitter } from '../../../lib/sfx-bus' import useEditor from '../../../store/use-editor' import { CursorSphere } from '../shared/cursor-sphere' +declare module 'react/jsx-runtime' { + namespace JSX { + interface IntrinsicElements extends ThreeElements {} + } +} + /** * Module-level flag to prevent the SelectionManager from deselecting * on the grid:click that fires right after a box-select drag completes. @@ -250,6 +260,11 @@ function collectNodeIdsInBounds(bounds: Bounds): string[] { if (objectBoundsIntersectsBounds(node.id, bounds)) { result.push(node.id) } + } else if (node.type === 'column') { + const column = node as ColumnNode + if (objectBoundsIntersectsBounds(column.id, bounds)) { + result.push(column.id) + } } else if (node.type === 'item') { const item = node as ItemNode if (item.asset.category === 'door' || item.asset.category === 'window') continue diff --git a/packages/editor/src/components/tools/wall/move-wall-tool.tsx b/packages/editor/src/components/tools/wall/move-wall-tool.tsx index f4af6a058..36cc061b5 100644 --- a/packages/editor/src/components/tools/wall/move-wall-tool.tsx +++ b/packages/editor/src/components/tools/wall/move-wall-tool.tsx @@ -2,20 +2,35 @@ import { type AnyNodeId, + constrainWallMoveDeltaToAxis, + DEFAULT_WALL_HEIGHT, + detectSpacesForLevel, emitter, type GridEvent, + getMaterialPresetByRef, + getPerpendicularWallMoveAxis, pauseSceneHistory, + planAutoSlabsForLevel, + planWallMoveJunctions, + resolveMaterial, resumeSceneHistory, + type SlabNode, useScene, + type WallMoveAxis, + type WallMoveBridgePlan, + type WallMoveJunctionPlan, type WallNode, + WallNode as WallSchema, } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' -import { useCallback, useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { BufferGeometry, DoubleSide, Float32BufferAttribute } from 'three' import { markToolCancelConsumed } from '../../../hooks/use-keyboard' +import { EDITOR_LAYER } from '../../../lib/constants' import { sfxEmitter } from '../../../lib/sfx-bus' import useEditor from '../../../store/use-editor' import { CursorSphere } from '../shared/cursor-sphere' -import { getWallGridStep, snapScalarToGrid } from './wall-drafting' +import { getWallGridStep, isWallLongEnough, snapScalarToGrid } from './wall-drafting' function rotateVector([x, z]: [number, number], angle: number): [number, number] { const cos = Math.cos(angle) @@ -27,6 +42,10 @@ function samePoint(a: [number, number], b: [number, number]) { return a[0] === b[0] && a[1] === b[1] } +function pointKey(point: [number, number]) { + return `${point[0]}:${point[1]}` +} + function stripWallIsNewMetadata(meta: WallNode['metadata']): WallNode['metadata'] { if (!meta || typeof meta !== 'object' || Array.isArray(meta)) { return meta @@ -37,10 +56,14 @@ function stripWallIsNewMetadata(meta: WallNode['metadata']): WallNode['metadata' return nextMeta as WallNode['metadata'] } -type LinkedWallSnapshot = { - id: WallNode['id'] +type LinkedWallSnapshot = WallNode + +type GhostWallPreview = { + id: string start: [number, number] end: [number, number] + color: string + height: number } function getLinkedWallSnapshots(args: { @@ -51,32 +74,42 @@ function getLinkedWallSnapshots(args: { }) { const { wallId, wallParentId, originalStart, originalEnd } = args const { nodes } = useScene.getState() - const snapshots: LinkedWallSnapshot[] = [] + const walls = Object.values(nodes).filter( + (node): node is WallNode => + node?.type === 'wall' && node.id !== wallId && (node.parentId ?? null) === wallParentId, + ) + const directlyLinkedWalls = walls.filter( + (wall) => + samePoint(wall.start, originalStart) || + samePoint(wall.start, originalEnd) || + samePoint(wall.end, originalStart) || + samePoint(wall.end, originalEnd), + ) + const contextPoints = new Set([pointKey(originalStart), pointKey(originalEnd)]) - for (const node of Object.values(nodes)) { - if (!(node?.type === 'wall' && node.id !== wallId)) { - continue - } + for (const wall of directlyLinkedWalls) { + contextPoints.add(pointKey(wall.start)) + contextPoints.add(pointKey(wall.end)) + } - if ((node.parentId ?? null) !== wallParentId) { + const snapshots: LinkedWallSnapshot[] = [] + const seenWallIds = new Set() + + for (const node of walls) { + if (!contextPoints.has(pointKey(node.start)) && !contextPoints.has(pointKey(node.end))) { continue } - if ( - !( - samePoint(node.start, originalStart) || - samePoint(node.start, originalEnd) || - samePoint(node.end, originalStart) || - samePoint(node.end, originalEnd) - ) - ) { + if (seenWallIds.has(node.id)) { continue } + seenWallIds.add(node.id) snapshots.push({ - id: node.id, + ...node, start: [...node.start] as [number, number], end: [...node.end] as [number, number], + children: [...(node.children ?? [])], }) } @@ -84,25 +117,283 @@ function getLinkedWallSnapshots(args: { } function getLinkedWallUpdates( - linkedWalls: LinkedWallSnapshot[], + linkedWalls: Array<{ + wall: LinkedWallSnapshot + matchPoint?: [number, number] + targetPoint?: [number, number] + }>, + originalStart: [number, number], + originalEnd: [number, number], + nextStart: [number, number], + nextEnd: [number, number], +) { + return linkedWalls.map(({ wall, matchPoint, targetPoint }) => { + if (matchPoint && targetPoint) { + return { + id: wall.id, + start: samePoint(wall.start, matchPoint) ? targetPoint : wall.start, + end: samePoint(wall.end, matchPoint) ? targetPoint : wall.end, + } + } + + const targetStart = targetPoint ?? nextStart + const targetEnd = targetPoint ?? nextEnd + + return { + id: wall.id, + start: samePoint(wall.start, originalStart) + ? targetStart + : samePoint(wall.start, originalEnd) + ? targetEnd + : wall.start, + end: samePoint(wall.end, originalStart) + ? targetStart + : samePoint(wall.end, originalEnd) + ? targetEnd + : wall.end, + } + }) +} + +function getPlannedLinkedWallUpdates( + plan: WallMoveJunctionPlan, originalStart: [number, number], originalEnd: [number, number], nextStart: [number, number], nextEnd: [number, number], ) { - return linkedWalls.map((wall) => ({ - id: wall.id, - start: samePoint(wall.start, originalStart) - ? nextStart - : samePoint(wall.start, originalEnd) - ? nextEnd - : wall.start, - end: samePoint(wall.end, originalStart) - ? nextStart - : samePoint(wall.end, originalEnd) - ? nextEnd - : wall.end, - })) + 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>, + 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['nodes'], + updates: Array<{ id: AnyNodeId; data: Partial }>, +) { + 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['nodes']) { + return Object.values(nodes).filter( + (entry): entry is SlabNode => entry?.type === 'slab' && (entry.parentId ?? null) === levelId, + ) +} + +function getLevelAutoSlabs( + levelId: string, + nodes: ReturnType['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> + 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> + nextStart: [number, number] + nextEnd: [number, number] + existingWalls: WallNode[] +}): Array<{ ghost: GhostWallPreview; wall: WallNode }> { + const { bridgePlans, nextStart, nextEnd, existingWalls } = args + const wallsForDuplicateCheck: Array> = [...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 ( + + + + + + + ) } export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => { @@ -123,7 +414,10 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => { (node.end[0] - node.start[0]) / 2, (node.end[1] - node.start[1]) / 2, ]) - const linkedOriginalsRef = useRef( + const moveAxisRef = useRef( + getPerpendicularWallMoveAxis(node.start, node.end), + ) + const linkedOriginalsRef = useRef( isNew ? [] : getLinkedWallSnapshots({ @@ -133,6 +427,9 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => { originalEnd: node.end, }), ) + const originalAutoSlabsRef = useRef( + node.parentId ? getLevelAutoSlabSnapshots(node.parentId) : [], + ) const dragAnchorRef = useRef<[number, number] | null>(null) const nodeIdRef = useRef(node.id) const previewRef = useRef<{ start: [number, number]; end: [number, number] } | null>(null) @@ -144,6 +441,7 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => { const centerZ = (node.start[1] + node.end[1]) / 2 return [centerX, 0, centerZ] }) + const [ghostWallPreviews, setGhostWallPreviews] = useState([]) const exitMoveMode = useCallback(() => { useEditor.getState().setMovingNode(null) @@ -155,9 +453,11 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => { const originalEnd = originalEndRef.current const originalCenter = originalCenterRef.current const originalHalfVector = originalHalfVectorRef.current + const levelId = node.parentId ?? null + const originalAutoSlabs = originalAutoSlabsRef.current pauseSceneHistory(useScene) - let wasCommitted = false + let shouldRestoreOnCleanup = true const applyNodePreview = ( updates: Array<{ id: WallNode['id']; start: [number, number]; end: [number, number] }>, @@ -173,6 +473,72 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => { } } + const applyLiveAutoSlabPreview = (walls: WallNode[]) => { + if (!levelId) { + return + } + + const levelWalls = walls.filter((wall) => (wall.parentId ?? null) === levelId) + const sceneState = useScene.getState() + const { roomPolygons } = detectSpacesForLevel(levelId, levelWalls) + const slabPlan = planAutoSlabsForLevel(roomPolygons, getLevelSlabs(levelId, sceneState.nodes)) + + if ( + slabPlan.create.length === 0 && + slabPlan.update.length === 0 && + slabPlan.delete.length === 0 + ) { + return + } + + sceneState.applyNodeChanges({ + update: slabPlan.update.map((entry) => ({ + id: entry.id as AnyNodeId, + data: entry.data, + })), + create: slabPlan.create.map((slab) => ({ + node: slab, + parentId: levelId as AnyNodeId, + })), + delete: slabPlan.delete.map((id) => id as AnyNodeId), + }) + } + + const restoreAutoSlabPreview = () => { + if (!levelId) { + return + } + + const sceneState = useScene.getState() + const originalIds = new Set(originalAutoSlabs.map((slab) => slab.id)) + const currentAutoSlabs = getLevelAutoSlabs(levelId, sceneState.nodes) + const update = originalAutoSlabs + .filter((slab) => sceneState.nodes[slab.id as AnyNodeId]) + .map((slab) => ({ + id: slab.id as AnyNodeId, + data: cloneSlabSnapshot(slab), + })) + const create = originalAutoSlabs + .filter((slab) => !sceneState.nodes[slab.id as AnyNodeId]) + .map((slab) => ({ + node: cloneSlabSnapshot(slab), + parentId: levelId as AnyNodeId, + })) + const deleteIds = currentAutoSlabs + .filter((slab) => !originalIds.has(slab.id)) + .map((slab) => slab.id as AnyNodeId) + + if (update.length === 0 && create.length === 0 && deleteIds.length === 0) { + return + } + + sceneState.applyNodeChanges({ + update, + create, + delete: deleteIds, + }) + } + const buildWallFromCenter = (center: [number, number]) => { const rotatedHalf = rotateVector(originalHalfVector, pendingRotationRef.current) const nextStart: [number, number] = [center[0] - rotatedHalf[0], center[1] - rotatedHalf[1]] @@ -180,28 +546,77 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => { return { start: nextStart, end: nextEnd } } + const getMovePlan = (nextStart: [number, number], nextEnd: [number, number]) => + planWallMoveJunctions( + linkedOriginalsRef.current, + originalStart, + originalEnd, + nextStart, + nextEnd, + ) + + const getLinkedPreviewUpdates = ( + plan: WallMoveJunctionPlan, + nextStart: [number, number], + nextEnd: [number, number], + ) => { + const movedUpdates = getPlannedLinkedWallUpdates( + plan, + originalStart, + originalEnd, + nextStart, + nextEnd, + ) + const movedById = new Map(movedUpdates.map((entry) => [entry.id, entry])) + + return linkedOriginalsRef.current.map( + (wall) => movedById.get(wall.id) ?? { id: wall.id, start: wall.start, end: wall.end }, + ) + } + const applyPreview = (nextStart: [number, number], nextEnd: [number, number]) => { previewRef.current = { start: nextStart, end: nextEnd } const centerX = (nextStart[0] + nextEnd[0]) / 2 const centerZ = (nextStart[1] + nextEnd[1]) / 2 setCursorLocalPos([centerX, 0, centerZ]) - applyNodePreview([ + const previewPlan = getMovePlan(nextStart, nextEnd) + const previewUpdates = [ { id: nodeId, start: nextStart, end: nextEnd }, - ...getLinkedWallUpdates( - linkedOriginalsRef.current, - originalStart, - originalEnd, - nextStart, - nextEnd, - ), + ...getLinkedPreviewUpdates(previewPlan, nextStart, nextEnd), + ] + const previewCollapsedWallIds = new Set([ + ...previewUpdates + .filter((entry) => entry.id !== nodeId && !isWallLongEnough(entry.start, entry.end)) + .map((entry) => entry.id as AnyNodeId), + ...previewPlan.wallsToDelete.map((wall) => wall.id as AnyNodeId), ]) + const previewSceneWalls = getWallsAfterUpdates( + useScene.getState().nodes, + previewUpdates.map((entry) => ({ + id: entry.id as AnyNodeId, + data: { start: entry.start, end: entry.end }, + })), + ).filter((wall) => !previewCollapsedWallIds.has(wall.id as AnyNodeId)) + const bridgePreviews = buildBridgeWallPreviews({ + bridgePlans: previewPlan.bridgePlans, + nextStart, + nextEnd, + existingWalls: previewSceneWalls, + }) + const nextGhostWalls = bridgePreviews.map((preview) => preview.ghost) + const virtualBridgeWalls = bridgePreviews.map((preview) => preview.wall) + setGhostWallPreviews(nextGhostWalls) + applyNodePreview(previewUpdates) + applyLiveAutoSlabPreview([...previewSceneWalls, ...virtualBridgeWalls]) } const restoreOriginal = () => { + setGhostWallPreviews([]) applyNodePreview([ { id: nodeId, start: originalStart, end: originalEnd }, ...linkedOriginalsRef.current, ]) + restoreAutoSlabPreview() } const onGridMove = (event: GridEvent) => { @@ -211,19 +626,24 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => { const localX = shiftPressedRef.current ? rawX : snapScalarToGrid(rawX, snapStep) const localZ = shiftPressedRef.current ? rawZ : snapScalarToGrid(rawZ, snapStep) + const anchor = dragAnchorRef.current ?? [localX, localZ] + dragAnchorRef.current = anchor + + const [deltaX, deltaZ] = constrainWallMoveDeltaToAxis( + localX - anchor[0], + localZ - anchor[1], + moveAxisRef.current, + ) + const constrainedGridPos: [number, number] = [anchor[0] + deltaX, anchor[1] + deltaZ] + if ( previousGridPosRef.current && - (localX !== previousGridPosRef.current[0] || localZ !== previousGridPosRef.current[1]) + (constrainedGridPos[0] !== previousGridPosRef.current[0] || + constrainedGridPos[1] !== previousGridPosRef.current[1]) ) { sfxEmitter.emit('sfx:grid-snap') } - previousGridPosRef.current = [localX, localZ] - - const anchor = dragAnchorRef.current ?? [localX, localZ] - dragAnchorRef.current = anchor - - const deltaX = localX - anchor[0] - const deltaZ = localZ - anchor[1] + previousGridPosRef.current = constrainedGridPos const nextCenter: [number, number] = [originalCenter[0] + deltaX, originalCenter[1] + deltaZ] const nextWall = buildWallFromCenter(nextCenter) @@ -238,16 +658,32 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => { const preview = previewRef.current ?? { start: originalStart, end: originalEnd } - wasCommitted = true + shouldRestoreOnCleanup = false // Restore original baseline while paused so the next resume+update // registers as a single tracked change (undo reverts to original). + setGhostWallPreviews([]) applyNodePreview([ { id: nodeId, start: originalStart, end: originalEnd }, ...linkedOriginalsRef.current, ]) + restoreAutoSlabPreview() resumeSceneHistory(useScene) + const commitPlan = getMovePlan(preview.start, preview.end) + const linkedWallUpdates = getPlannedLinkedWallUpdates( + commitPlan, + originalStart, + originalEnd, + preview.start, + preview.end, + ) + const collapsedLinkedWallIds = new Set([ + ...linkedWallUpdates + .filter((entry) => !isWallLongEnough(entry.start, entry.end)) + .map((entry) => entry.id as AnyNodeId), + ...commitPlan.wallsToDelete.map((wall) => wall.id as AnyNodeId), + ]) const commitUpdates = [ { @@ -260,21 +696,29 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => { } : { start: preview.start, end: preview.end }, }, - ...getLinkedWallUpdates( - linkedOriginalsRef.current, - originalStart, - originalEnd, - preview.start, - preview.end, - ).map((entry) => ({ - id: entry.id as AnyNodeId, - data: { start: entry.start, end: entry.end }, - })), + ...linkedWallUpdates + .filter((entry) => !collapsedLinkedWallIds.has(entry.id as AnyNodeId)) + .map((entry) => ({ + id: entry.id as AnyNodeId, + data: { start: entry.start, end: entry.end }, + })), ] - useScene.getState().updateNodes(commitUpdates) - for (const { id } of commitUpdates) { - useScene.getState().markDirty(id) - } + const sceneState = useScene.getState() + const existingWalls = getWallsAfterUpdates(sceneState.nodes, commitUpdates).filter( + (wall) => !collapsedLinkedWallIds.has(wall.id as AnyNodeId), + ) + const bridgeCreates = buildBridgeWallCreates({ + bridgePlans: commitPlan.bridgePlans, + nextStart: preview.start, + nextEnd: preview.end, + existingWalls, + wallCount: Object.values(sceneState.nodes).filter((entry) => entry?.type === 'wall').length, + }) + sceneState.applyNodeChanges({ + update: commitUpdates, + create: bridgeCreates, + delete: Array.from(collapsedLinkedWallIds), + }) pauseSceneHistory(useScene) @@ -313,6 +757,7 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => { (preview.start[1] + preview.end[1]) / 2, ] const nextWall = buildWallFromCenter(currentCenter) + moveAxisRef.current = getPerpendicularWallMoveAxis(nextWall.start, nextWall.end) applyPreview(nextWall.start, nextWall.end) } @@ -323,6 +768,7 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => { } const onCancel = () => { + shouldRestoreOnCleanup = false restoreOriginal() useViewer.getState().setSelection({ selectedIds: [nodeId] }) resumeSceneHistory(useScene) @@ -337,7 +783,7 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => { window.addEventListener('keyup', onKeyUp) return () => { - if (!wasCommitted) { + if (shouldRestoreOnCleanup) { restoreOriginal() } shiftPressedRef.current = false @@ -348,11 +794,14 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => { window.removeEventListener('keydown', onKeyDown) window.removeEventListener('keyup', onKeyUp) } - }, [exitMoveMode, isNew, node.metadata]) + }, [exitMoveMode, isNew, node.metadata, node.parentId]) return ( + {ghostWallPreviews.map((preview) => ( + + ))} ) } diff --git a/packages/editor/src/components/ui/floating-level-selector.tsx b/packages/editor/src/components/ui/floating-level-selector.tsx index 0ba9355e9..240cf5508 100644 --- a/packages/editor/src/components/ui/floating-level-selector.tsx +++ b/packages/editor/src/components/ui/floating-level-selector.tsx @@ -26,7 +26,7 @@ import { useScene, } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' -import { Copy, GripVertical, MoreVertical, Plus, Trash2 } from 'lucide-react' +import { ClipboardPaste, Copy, GripVertical, MoreVertical, Plus, Trash2 } from 'lucide-react' import { type ButtonHTMLAttributes, type CSSProperties, @@ -34,6 +34,7 @@ import { useEffect, useRef, useState, + useSyncExternalStore, } from 'react' import { useShallow } from 'zustand/react/shallow' import { @@ -41,6 +42,12 @@ import { type LevelDuplicatePreset, } from '../../lib/level-duplication' import { deleteLevelWithFallbackSelection } from '../../lib/level-selection' +import { + getEditorClipboardSnapshot, + pasteEditorClipboardToLevel, + subscribeEditorClipboard, +} from '../../lib/scene-clipboard' +import { sfxEmitter } from '../../lib/sfx-bus' import { cn } from '../../lib/utils' import { LevelDuplicateDialog } from './level-duplicate-dialog' import { @@ -126,6 +133,7 @@ function LevelRow({ dragHandleRef, onSelect, onDuplicate, + onPaste, onRequestDelete, }: { level: LevelNode @@ -135,6 +143,7 @@ function LevelRow({ dragHandleRef?: (element: HTMLButtonElement | null) => void onSelect: () => void onDuplicate: (preset?: LevelDuplicatePreset) => void + onPaste?: () => void onRequestDelete: () => void }) { const [duplicateDialogOpen, setDuplicateDialogOpen] = useState(false) @@ -223,6 +232,19 @@ function LevelRow({ Duplicate with options... + {onPaste && ( + + )} + ) + })} +
+ {isBraceSupport ? ( + <> + handleUpdate({ braceWidth: value, width: value })} + precision={2} + step={0.01} + unit="m" + value={node.braceWidth ?? node.width} + /> + handleUpdate({ braceDepth: value, depth: value })} + precision={2} + step={0.01} + unit="m" + value={node.braceDepth ?? node.depth} + /> + + ) : ( + <> + + handleUpdate({ edgeSoftness: value })} + precision={3} + step={0.005} + unit="m" + value={node.edgeSoftness ?? 0.025} + /> + {(node.crossSection === 'square' || node.crossSection === 'rectangular') && ( + handleUpdate({ shaftCornerRadius: value })} + precision={3} + step={0.005} + unit="m" + value={node.shaftCornerRadius ?? 0.035} + /> + )} + )} - + {!isBraceSupport && ( + + )} - - handleUpdate({ - width: value, - radius: value / 2, - ...(node.crossSection === 'rectangular' ? {} : { depth: value }), - }) - } - precision={2} - step={0.02} - unit="m" - value={node.width} - /> - {node.crossSection === 'rectangular' && ( - handleUpdate({ depth: value })} - precision={2} - step={0.02} - unit="m" - value={node.depth} - /> - )} - - - - - {shaftProfile === 'straight' && ( - handleUpdate({ shaftStartScale: value, shaftEndScale: value })} - precision={2} - step={0.02} - value={node.shaftStartScale ?? 0.72} - /> - )} - {shaftProfile === 'tapered' && ( + {isBraceSupport ? ( <> + {(supportStyle === 'a-frame' || + supportStyle === 'x-brace' || + supportStyle === 'k-brace' || + supportStyle === 'single-strut' || + supportStyle === 'tripod' || + supportStyle === 'trestle' || + supportStyle === 'portal-frame' || + supportStyle === 'box-frame') && ( + + 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} + /> + )} handleUpdate({ shaftStartScale: value })} - precision={2} - step={0.02} - value={node.shaftStartScale ?? 0.82} - /> - handleUpdate({ shaftEndScale: value })} + 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} - value={node.shaftEndScale ?? 0.72} + 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) + } /> - handleUpdate({ shaftTaper: value })} - precision={2} - step={0.01} - value={node.shaftTaper ?? 0.14} + handleUpdate({ bracePlateEnabled: checked })} /> - )} - {shaftProfile === 'bulged' && ( + ) : ( <> handleUpdate({ shaftStartScale: value, shaftEndScale: value })} + label="Width" + max={1.6} + min={0.12} + onChange={(value) => + handleUpdate({ + width: value, + radius: value / 2, + ...(node.crossSection === 'rectangular' ? {} : { depth: value }), + }) + } precision={2} step={0.02} - value={node.shaftStartScale ?? 0.68} - /> - handleUpdate({ shaftBulge: value })} - precision={2} - step={0.01} - value={node.shaftBulge ?? 0.12} + unit="m" + value={node.width} /> + {node.crossSection === 'rectangular' && ( + handleUpdate({ depth: value })} + precision={2} + step={0.02} + unit="m" + value={node.depth} + /> + )} )} - {shaftProfile === 'hourglass' && ( - <> + + + {!isBraceSupport && ( + + + {shaftProfile === 'straight' && ( handleUpdate({ shaftStartScale: value, shaftEndScale: value })} precision={2} step={0.02} - value={node.shaftStartScale ?? 0.84} + value={node.shaftStartScale ?? 0.72} /> + )} + {shaftProfile === 'tapered' && ( + <> + handleUpdate({ shaftStartScale: value })} + precision={2} + step={0.02} + value={node.shaftStartScale ?? 0.82} + /> + handleUpdate({ shaftEndScale: value })} + precision={2} + step={0.02} + value={node.shaftEndScale ?? 0.72} + /> + handleUpdate({ shaftTaper: value })} + precision={2} + step={0.01} + value={node.shaftTaper ?? 0.14} + /> + + )} + {shaftProfile === 'bulged' && ( + <> + + handleUpdate({ shaftStartScale: value, shaftEndScale: value }) + } + precision={2} + step={0.02} + value={node.shaftStartScale ?? 0.68} + /> + handleUpdate({ shaftBulge: value })} + precision={2} + step={0.01} + value={node.shaftBulge ?? 0.12} + /> + + )} + {shaftProfile === 'hourglass' && ( + <> + + handleUpdate({ shaftStartScale: value, shaftEndScale: value }) + } + precision={2} + step={0.02} + value={node.shaftStartScale ?? 0.84} + /> + handleUpdate({ shaftBulge: value })} + precision={2} + step={0.01} + value={node.shaftBulge ?? 0.12} + /> + + )} + + handleUpdate({ + shaftTwistStep: value, + ...(Math.abs(value) > 0.001 && (node.shaftSegmentCount ?? 1) < 8 + ? { shaftSegmentCount: 12 } + : {}), + }) + } + precision={0} + step={5} + unit="°" + value={node.shaftTwistStep ?? 0} + /> + {Math.abs(node.shaftTwistStep ?? 0) > 0.001 && ( handleUpdate({ shaftBulge: value })} - precision={2} - step={0.01} - value={node.shaftBulge ?? 0.12} + label="Twist Segments" + max={48} + min={4} + onChange={(value) => handleUpdate({ shaftSegmentCount: Math.round(value) })} + precision={0} + step={1} + value={node.shaftSegmentCount ?? 12} /> - - )} - - handleUpdate({ - shaftTwistStep: value, - ...(Math.abs(value) > 0.001 && (node.shaftSegmentCount ?? 1) < 8 - ? { shaftSegmentCount: 12 } - : {}), - }) - } - precision={0} - step={5} - unit="°" - value={node.shaftTwistStep ?? 0} - /> - {Math.abs(node.shaftTwistStep ?? 0) > 0.001 && ( + )} handleUpdate({ shaftSegmentCount: Math.round(value) })} + label="Ring Pairs" + max={4} + min={0} + onChange={(value) => + handleUpdate({ + ringCount: Math.round(value) * 2, + ringPlacement: 'ends', + ringSpread: node.ringSpread ?? 0.16, + ringThickness: node.ringThickness ?? 0.055, + }) + } precision={0} step={1} - value={node.shaftSegmentCount ?? 12} - /> - )} - - handleUpdate({ - ringCount: Math.round(value) * 2, - ringPlacement: 'ends', - ringSpread: node.ringSpread ?? 0.16, - ringThickness: node.ringThickness ?? 0.055, - }) - } - precision={0} - step={1} - value={Math.ceil((node.ringCount ?? 0) / 2)} - /> - {(node.ringCount ?? 0) > 0 && ( - handleUpdate({ ringThickness: value })} - precision={3} - step={0.005} - unit="m" - value={node.ringThickness ?? 0.055} + value={Math.ceil((node.ringCount ?? 0) / 2)} /> - )} - {(node.ringCount ?? 0) > 0 && ( - handleUpdate({ ringSpread: value, ringPlacement: 'ends' })} - precision={2} - step={0.01} - value={node.ringSpread ?? 0.16} - /> - )} - + {(node.ringCount ?? 0) > 0 && ( + handleUpdate({ ringThickness: value })} + precision={3} + step={0.005} + unit="m" + value={node.ringThickness ?? 0.055} + /> + )} + {(node.ringCount ?? 0) > 0 && ( + handleUpdate({ ringSpread: value, ringPlacement: 'ends' })} + precision={2} + step={0.01} + value={node.ringSpread ?? 0.16} + /> + )} + + )} - + {!isBraceSupport && ( +