Add column hit testing and align column defaults

This commit is contained in:
sudhir
2026-05-08 14:31:58 +05:30
parent 53393b7def
commit 0514363136
13 changed files with 532 additions and 83 deletions
+16 -16
View File
@@ -88,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),
@@ -179,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,
@@ -229,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,
@@ -279,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,
@@ -329,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,
@@ -379,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,
@@ -430,7 +430,7 @@ export const COLUMN_PRESETS = {
supportStyle: 'a-frame',
style: 'faceted',
crossSection: 'rectangular',
height: 2.6,
height: 2.5,
radius: 0.08,
width: 0.16,
depth: 0.16,
@@ -486,7 +486,7 @@ export const COLUMN_PRESETS = {
supportStyle: 'y-frame',
style: 'faceted',
crossSection: 'rectangular',
height: 2.7,
height: 2.5,
radius: 0.08,
width: 0.16,
depth: 0.16,
@@ -542,7 +542,7 @@ export const COLUMN_PRESETS = {
supportStyle: 'v-frame',
style: 'faceted',
crossSection: 'rectangular',
height: 2.6,
height: 2.5,
radius: 0.08,
width: 0.16,
depth: 0.16,
@@ -598,7 +598,7 @@ export const COLUMN_PRESETS = {
supportStyle: 'x-brace',
style: 'faceted',
crossSection: 'rectangular',
height: 2.6,
height: 2.5,
radius: 0.07,
width: 0.14,
depth: 0.14,
@@ -654,7 +654,7 @@ export const COLUMN_PRESETS = {
supportStyle: 'k-brace',
style: 'faceted',
crossSection: 'rectangular',
height: 2.6,
height: 2.5,
radius: 0.07,
width: 0.14,
depth: 0.14,
@@ -710,7 +710,7 @@ export const COLUMN_PRESETS = {
supportStyle: 'single-strut',
style: 'faceted',
crossSection: 'rectangular',
height: 2.6,
height: 2.5,
radius: 0.07,
width: 0.14,
depth: 0.14,
@@ -766,7 +766,7 @@ export const COLUMN_PRESETS = {
supportStyle: 'tripod',
style: 'faceted',
crossSection: 'rectangular',
height: 2.6,
height: 2.5,
radius: 0.07,
width: 0.14,
depth: 0.14,
@@ -822,7 +822,7 @@ export const COLUMN_PRESETS = {
supportStyle: 'trestle',
style: 'faceted',
crossSection: 'rectangular',
height: 2.6,
height: 2.5,
radius: 0.07,
width: 0.14,
depth: 0.14,
@@ -878,7 +878,7 @@ export const COLUMN_PRESETS = {
supportStyle: 'portal-frame',
style: 'faceted',
crossSection: 'rectangular',
height: 2.6,
height: 2.5,
radius: 0.08,
width: 0.16,
depth: 0.16,
@@ -934,7 +934,7 @@ export const COLUMN_PRESETS = {
supportStyle: 'box-frame',
style: 'faceted',
crossSection: 'rectangular',
height: 2.6,
height: 2.5,
radius: 0.07,
width: 0.14,
depth: 0.14,
@@ -625,6 +625,12 @@ type FloorplanSpawnEntry = {
rotation: number
}
type FloorplanColumnEntry = {
column: ColumnNode
points: string
polygon: Point2D[]
}
type ReferenceFloorData = {
ceilingPolygons: CeilingPolygonEntry[]
columnEntries: ReferenceFloorColumnEntry[]
@@ -8189,6 +8195,28 @@ export function FloorplanPanel() {
: entry,
)
}, [zoneBoundaryDraft, zonePolygons])
const floorplanColumnEntries = useMemo<FloorplanColumnEntry[]>(
() =>
levelDescendantNodes.flatMap((node) => {
if (!(node.type === 'column' && node.visible !== false)) {
return []
}
const polygon = getColumnPlanFootprint(node)
if (polygon.length < 3) {
return []
}
return [
{
column: node,
points: formatPolygonPoints(polygon),
polygon,
},
]
}),
[levelDescendantNodes],
)
const levelDescendantNodeById = useMemo(
() => new Map(levelDescendantNodes.map((node) => [node.id, node] as const)),
[levelDescendantNodes],
@@ -13016,6 +13044,7 @@ export function FloorplanPanel() {
)
const { getFloorplanHitIdAtPoint, getFloorplanSelectionIdsInBounds } = useFloorplanHitTesting({
ceilingPolygons: displayCeilingPolygons,
columnPolygons: floorplanColumnEntries,
displaySlabPolygons,
displayWallPolygons,
floorplanItemEntries,
@@ -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,
@@ -13,7 +13,6 @@ import {
import { useEffect, useRef, useState } from 'react'
import type { Group } from 'three'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import { CursorSphere } from '../shared/cursor-sphere'
const COLUMN_ICON = (
@@ -70,8 +69,6 @@ export const ColumnTool: React.FC<ColumnToolProps> = ({ currentLevelId, onPlaced
useScene.getState().createNode(column, currentLevelId)
onPlaced?.(column.id)
sfxEmitter.emit('sfx:structure-build')
useEditor.getState().setTool(null)
useEditor.getState().setMode('select')
}
emitter.on('grid:move', onGridMove)
@@ -88,7 +85,7 @@ export const ColumnTool: React.FC<ColumnToolProps> = ({ currentLevelId, onPlaced
return (
<CursorSphere
color="#a78bfa"
height={2.8}
height={2.5}
ref={cursorRef}
showTooltip
tooltipContent={COLUMN_ICON}
@@ -20,6 +20,9 @@ import {
export type FencePlanPoint = WallPlanPoint
const FENCE_CORNER_SNAP_RADIUS = 0.28
const FENCE_SPAN_SNAP_RADIUS = 0.16
type SegmentNode = {
start: FencePlanPoint
end: FencePlanPoint
@@ -57,46 +60,68 @@ function findFenceSnapTarget(
fences: FenceNode[],
ignoreFenceIds: string[] = [],
): FencePlanPoint | null {
const radiusSquared = 0.35 ** 2
const cornerRadiusSquared = FENCE_CORNER_SNAP_RADIUS ** 2
const spanRadiusSquared = FENCE_SPAN_SNAP_RADIUS ** 2
const ignoredFenceIds = new Set(ignoreFenceIds)
let bestTarget: FencePlanPoint | null = null
let bestDistanceSquared = Number.POSITIVE_INFINITY
let bestCornerTarget: FencePlanPoint | null = null
let bestCornerDistanceSquared = Number.POSITIVE_INFINITY
let bestSpanTarget: FencePlanPoint | null = null
let bestSpanDistanceSquared = Number.POSITIVE_INFINITY
for (const fence of fences) {
if (ignoredFenceIds.has(fence.id)) {
continue
}
const candidates: Array<FencePlanPoint | null> = [fence.start, fence.end]
if (isCurvedWall(fence)) {
const sampleCount = Math.max(8, Math.ceil(getWallCurveLength(fence) / 0.3))
for (let index = 0; index <= sampleCount; index += 1) {
const frame = getWallCurveFrameAt(fence, index / sampleCount)
candidates.push([frame.point.x, frame.point.y])
for (const candidate of [fence.start, fence.end]) {
const candidateDistanceSquared = distanceSquared(point, candidate)
if (
candidateDistanceSquared > cornerRadiusSquared ||
candidateDistanceSquared >= bestCornerDistanceSquared
) {
continue
}
} else {
candidates.push(projectPointOntoSegment(point, fence))
bestCornerTarget = candidate
bestCornerDistanceSquared = candidateDistanceSquared
}
for (const candidate of candidates) {
if (isCurvedWall(fence)) {
const sampleCount = Math.max(8, Math.ceil(getWallCurveLength(fence) / 0.3))
for (let index = 1; index < sampleCount; index += 1) {
const frame = getWallCurveFrameAt(fence, index / sampleCount)
const candidate: FencePlanPoint = [frame.point.x, frame.point.y]
const candidateDistanceSquared = distanceSquared(point, candidate)
if (
candidateDistanceSquared > spanRadiusSquared ||
candidateDistanceSquared >= bestSpanDistanceSquared
) {
continue
}
bestSpanTarget = candidate
bestSpanDistanceSquared = candidateDistanceSquared
}
} else {
const candidate = projectPointOntoSegment(point, fence)
if (!candidate) {
continue
}
const candidateDistanceSquared = distanceSquared(point, candidate)
if (
candidateDistanceSquared > radiusSquared ||
candidateDistanceSquared >= bestDistanceSquared
candidateDistanceSquared > spanRadiusSquared ||
candidateDistanceSquared >= bestSpanDistanceSquared
) {
continue
}
bestTarget = candidate
bestDistanceSquared = candidateDistanceSquared
bestSpanTarget = candidate
bestSpanDistanceSquared = candidateDistanceSquared
}
}
return bestTarget
return bestCornerTarget ?? bestSpanTarget
}
export function snapFenceDraftPoint(args: {
@@ -25,8 +25,13 @@ import {
import { isWallLongEnough } from '../wall/wall-drafting'
import { type FencePlanPoint, snapFenceDraftPoint } from './fence-drafting'
const LINKED_FENCE_ENDPOINT_EPSILON = 0.025
function samePoint(a: FencePlanPoint, b: FencePlanPoint) {
return a[0] === b[0] && a[1] === b[1]
return (
Math.abs(a[0] - b[0]) <= LINKED_FENCE_ENDPOINT_EPSILON &&
Math.abs(a[1] - b[1]) <= LINKED_FENCE_ENDPOINT_EPSILON
)
}
type SegmentLike = {
@@ -114,10 +119,9 @@ type LinkedFenceSnapshot = {
function getLinkedFenceSnapshots(args: {
fenceId: FenceNode['id']
fenceParentId: string | null
originalStart: FencePlanPoint
originalEnd: FencePlanPoint
linkedPoint: FencePlanPoint
}) {
const { fenceId, fenceParentId, originalStart, originalEnd } = args
const { fenceId, fenceParentId, linkedPoint } = args
const { nodes } = useScene.getState()
const snapshots: LinkedFenceSnapshot[] = []
@@ -130,12 +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
}
@@ -152,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,
}))
}
@@ -181,6 +170,11 @@ export const MoveFenceEndpointTool: React.FC<{ target: MovingFenceEndpoint }> =
const nodeIdRef = useRef(target.fence.id)
const originalStartRef = useRef<FencePlanPoint>([...target.fence.start] as FencePlanPoint)
const originalEndRef = useRef<FencePlanPoint>([...target.fence.end] as FencePlanPoint)
const originalMovingPointRef = useRef<FencePlanPoint>(
target.endpoint === 'start'
? ([...target.fence.start] as FencePlanPoint)
: ([...target.fence.end] as FencePlanPoint),
)
const fixedPointRef = useRef<FencePlanPoint>(
target.endpoint === 'start'
? ([...target.fence.end] as FencePlanPoint)
@@ -190,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)
@@ -211,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(
@@ -244,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(
@@ -321,10 +309,8 @@ export const MoveFenceEndpointTool: React.FC<{ target: MovingFenceEndpoint }> =
? []
: getLinkedFenceUpdates(
linkedOriginalsRef.current,
originalStart,
originalEnd,
preview.start,
preview.end,
originalMovingPoint,
target.endpoint === 'start' ? preview.start : preview.end,
)),
])
pauseSceneHistory(useScene)
@@ -1,7 +1,10 @@
import '../../../three-types'
import { Icon } from '@iconify/react'
import {
type AnyNodeId,
type CeilingNode,
type ColumnNode,
emitter,
type GridEvent,
type ItemNode,
@@ -14,6 +17,7 @@ import {
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useThree } from '@react-three/fiber'
import type { ThreeElements } from '@react-three/fiber'
import { useEffect, useRef } from 'react'
import {
Box3,
@@ -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.
@@ -240,6 +250,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 (phase === 'structure' && structureLayer === 'zones') {
@@ -26,7 +26,7 @@ import {
useScene,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { Copy, GripVertical, MoreVertical, Plus, Trash2 } from 'lucide-react'
import { ClipboardPaste, Copy, GripVertical, MoreVertical, Plus, Trash2 } from 'lucide-react'
import {
type ButtonHTMLAttributes,
type CSSProperties,
@@ -34,6 +34,7 @@ import {
useEffect,
useRef,
useState,
useSyncExternalStore,
} from 'react'
import { useShallow } from 'zustand/react/shallow'
import {
@@ -41,6 +42,12 @@ import {
type LevelDuplicatePreset,
} from '../../lib/level-duplication'
import { deleteLevelWithFallbackSelection } from '../../lib/level-selection'
import {
getEditorClipboardSnapshot,
pasteEditorClipboardToLevel,
subscribeEditorClipboard,
} from '../../lib/scene-clipboard'
import { sfxEmitter } from '../../lib/sfx-bus'
import { cn } from '../../lib/utils'
import { LevelDuplicateDialog } from './level-duplicate-dialog'
import {
@@ -126,6 +133,7 @@ function LevelRow({
dragHandleRef,
onSelect,
onDuplicate,
onPaste,
onRequestDelete,
}: {
level: LevelNode
@@ -135,6 +143,7 @@ function LevelRow({
dragHandleRef?: (element: HTMLButtonElement | null) => void
onSelect: () => void
onDuplicate: (preset?: LevelDuplicatePreset) => void
onPaste?: () => void
onRequestDelete: () => void
}) {
const [duplicateDialogOpen, setDuplicateDialogOpen] = useState(false)
@@ -223,6 +232,19 @@ function LevelRow({
<Copy className="h-3 w-3" />
Duplicate with options...
</button>
{onPaste && (
<button
className="flex w-full items-center gap-2 rounded-md px-2.5 py-1.5 text-left text-muted-foreground text-xs transition-colors hover:bg-white/10 hover:text-foreground"
onClick={(e) => {
e.stopPropagation()
onPaste()
}}
type="button"
>
<ClipboardPaste className="h-3 w-3" />
Paste copied selection
</button>
)}
<button
className="flex w-full items-center gap-2 rounded-md px-2.5 py-1.5 text-left text-muted-foreground text-xs transition-colors hover:bg-white/10 hover:text-red-400"
onClick={(e) => {
@@ -256,12 +278,14 @@ function SortableLevelRow({
isSelected,
onSelect,
onDuplicate,
onPaste,
onRequestDelete,
}: {
level: LevelNode
isSelected: boolean
onSelect: () => void
onDuplicate: (preset?: LevelDuplicatePreset) => void
onPaste?: () => void
onRequestDelete: () => void
}) {
const {
@@ -291,6 +315,7 @@ function SortableLevelRow({
isSelected={isSelected}
level={level}
onDuplicate={onDuplicate}
onPaste={onPaste}
onRequestDelete={onRequestDelete}
onSelect={onSelect}
/>
@@ -310,6 +335,11 @@ export function FloatingLevelSelector() {
const [deletingLevel, setDeletingLevel] = useState<LevelNode | null>(null)
const [draggingLevelId, setDraggingLevelId] = useState<string | null>(null)
const clipboardSnapshot = useSyncExternalStore(
subscribeEditorClipboard,
getEditorClipboardSnapshot,
getEditorClipboardSnapshot,
)
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: { distance: 4 },
@@ -424,6 +454,13 @@ export function FloatingLevelSelector() {
[createNodes, levels, resolvedBuildingId, setSelection, updateNodes],
)
const handlePasteToLevel = useCallback((level: LevelNode) => {
const result = pasteEditorClipboardToLevel(level.id)
if (result?.pastedIds.length) {
sfxEmitter.emit('sfx:item-place')
}
}, [])
const handleDragStart = useCallback((event: DragStartEvent) => {
setDraggingLevelId(String(event.active.id))
}, [])
@@ -523,6 +560,9 @@ export function FloatingLevelSelector() {
isSelected={isSelected}
level={level}
onDuplicate={(preset) => handleDuplicateLevel(level, preset)}
onPaste={
clipboardSnapshot ? () => handlePasteToLevel(level) : undefined
}
onRequestDelete={() => setDeletingLevel(level)}
onSelect={() =>
setSelection(
+15
View File
@@ -3,6 +3,10 @@ import { useViewer } from '@pascal-app/viewer'
import { useEffect } from 'react'
import { closeDoorOpenState, toggleDoorOpenState } from '../lib/door-interaction'
import { runRedo, runUndo } from '../lib/history'
import {
copySelectedNodesToEditorClipboard,
pasteEditorClipboardToLevel,
} from '../lib/scene-clipboard'
import { sfxEmitter } from '../lib/sfx-bus'
import { closeWindowOpenState, toggleWindowOpenState } from '../lib/window-interaction'
import useEditor from '../store/use-editor'
@@ -105,6 +109,17 @@ export const useKeyboard = ({
useEditor.getState().setPhase('structure')
useEditor.getState().setStructureLayer('elements')
useEditor.getState().setMode('material-paint')
} else if (e.key === 'c' && (e.metaKey || e.ctrlKey) && !e.shiftKey) {
if (isVersionPreviewMode) return
e.preventDefault()
copySelectedNodesToEditorClipboard()
} else if (e.key === 'v' && (e.metaKey || e.ctrlKey) && !e.shiftKey) {
if (isVersionPreviewMode) return
e.preventDefault()
const result = pasteEditorClipboardToLevel()
if (result?.pastedIds.length) {
sfxEmitter.emit('sfx:item-place')
}
} else if (e.key === 'z' && (e.metaKey || e.ctrlKey)) {
if (isVersionPreviewMode) return
e.preventDefault()
@@ -1,5 +1,6 @@
import type {
CeilingNode,
ColumnNode,
DoorNode,
ItemNode,
Point2D,
@@ -53,6 +54,11 @@ type CeilingEntry = {
holes: Point2D[][]
}
type ColumnEntry = {
column: ColumnNode
polygon: Point2D[]
}
type RoofEntry = {
roof: RoofNode
segments: Array<{
@@ -71,6 +77,7 @@ type FloorplanSelectionToolContext = {
walls: WallEntry[]
slabs: SlabEntry[]
ceilings: CeilingEntry[]
columns: ColumnEntry[]
roofs: RoofEntry[]
openingHitTolerance: number
wallHitTolerance: number
@@ -123,6 +130,13 @@ export function getFloorplanHitNodeId(context: FloorplanSelectionToolContext) {
return stairHit.stair.id
}
const columnHit = context.columns.find(({ polygon }) =>
isPointInsidePolygon(context.point, polygon),
)
if (columnHit) {
return columnHit.column.id
}
const wallHit = context.walls.find(
({ wall, polygon }) =>
isPointInsidePolygon(context.point, polygon) ||
@@ -166,6 +180,7 @@ type FloorplanSelectionBoundsContext = {
openings: OpeningPolygonEntry[]
slabs: SlabEntry[]
ceilings: CeilingEntry[]
columns: ColumnEntry[]
stairs: StairEntry[]
roofs: RoofEntry[]
}
@@ -179,6 +194,7 @@ export function getFloorplanSelectionIdsInBounds({
openings,
slabs,
ceilings,
columns,
stairs,
roofs,
}: FloorplanSelectionBoundsContext) {
@@ -204,6 +220,9 @@ export function getFloorplanSelectionIdsInBounds({
const ceilingIds = ceilings
.filter(({ polygon }) => doesPolygonIntersectSelectionBounds(polygon, bounds))
.map(({ ceiling }) => ceiling.id)
const columnIds = columns
.filter(({ polygon }) => doesPolygonIntersectSelectionBounds(polygon, bounds))
.map(({ column }) => column.id)
const stairIds = stairs
.filter((stair) =>
getStairHitPolygons(stair).some((polygon) =>
@@ -224,6 +243,7 @@ export function getFloorplanSelectionIdsInBounds({
...openingIds,
...slabIds,
...ceilingIds,
...columnIds,
...stairIds,
...roofIds,
]),
+267
View File
@@ -0,0 +1,267 @@
import {
AnyNode,
type AnyNodeId,
generateId,
type LevelNode,
type StairNode,
useScene,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
type ClipboardPayload = {
copiedAt: number
nodes: AnyNode[]
rootIds: AnyNodeId[]
}
type PasteResult = {
pastedIds: AnyNodeId[]
skippedIds: AnyNodeId[]
}
const COPYABLE_ROOT_TYPES = new Set<AnyNode['type']>([
'wall',
'fence',
'column',
'item',
'slab',
'ceiling',
'roof',
'stair',
'spawn',
'zone',
])
let clipboardPayload: ClipboardPayload | null = null
const subscribers = new Set<() => void>()
function notifySubscribers() {
for (const subscriber of subscribers) {
subscriber()
}
}
export function subscribeEditorClipboard(subscriber: () => void) {
subscribers.add(subscriber)
return () => {
subscribers.delete(subscriber)
}
}
export function getEditorClipboardSnapshot() {
return clipboardPayload
}
export function hasEditorClipboard() {
return !!clipboardPayload && clipboardPayload.rootIds.length > 0
}
function extractIdPrefix(id: string) {
const underscoreIndex = id.indexOf('_')
return underscoreIndex === -1 ? 'node' : id.slice(0, underscoreIndex)
}
function collectSubtreeIds(
nodes: Record<AnyNodeId, AnyNode>,
rootId: AnyNodeId,
ids: Set<AnyNodeId>,
) {
if (ids.has(rootId)) return
const node = nodes[rootId]
if (!node) return
ids.add(rootId)
if ('children' in node && Array.isArray(node.children)) {
for (const childId of node.children as AnyNodeId[]) {
collectSubtreeIds(nodes, childId, ids)
}
}
}
function hasSelectedAncestor(
nodes: Record<AnyNodeId, AnyNode>,
id: AnyNodeId,
selectedIds: Set<AnyNodeId>,
) {
let parentId = nodes[id]?.parentId as AnyNodeId | null
while (parentId) {
if (selectedIds.has(parentId)) return true
parentId = nodes[parentId]?.parentId as AnyNodeId | null
}
return false
}
function isLevelChildRoot(nodes: Record<AnyNodeId, AnyNode>, node: AnyNode) {
const parentId = node.parentId as AnyNodeId | null
if (!parentId) return true
return nodes[parentId]?.type === 'level'
}
function getPasteTargetLevel(targetLevelId?: AnyNodeId) {
const scene = useScene.getState()
const resolvedLevelId =
targetLevelId ?? (useViewer.getState().selection.levelId as AnyNodeId | null)
if (!resolvedLevelId) return null
const level = scene.nodes[resolvedLevelId]
return level?.type === 'level' ? level : null
}
function getNextLevelId(level: LevelNode, nodes: Record<AnyNodeId, AnyNode>) {
const parentId = level.parentId as AnyNodeId | null
if (!parentId) return null
const building = nodes[parentId]
if (!building || building.type !== 'building') return null
const siblingLevels = building.children
.map((childId) => nodes[childId as AnyNodeId])
.filter((node): node is LevelNode => node?.type === 'level')
return (
siblingLevels
.filter((candidate) => candidate.level > level.level)
.sort((a, b) => a.level - b.level)[0]?.id ?? null
)
}
function remapNodeReferences(
node: AnyNode,
oldId: AnyNodeId,
targetLevel: LevelNode,
idMap: Map<AnyNodeId, AnyNodeId>,
rootIds: Set<AnyNodeId>,
nodes: Record<AnyNodeId, AnyNode>,
) {
const clone = JSON.parse(JSON.stringify(node)) as AnyNode
;(clone as Record<string, unknown>).id = idMap.get(oldId)
if (rootIds.has(oldId)) {
clone.parentId = targetLevel.id
} else if (clone.parentId && typeof clone.parentId === 'string') {
clone.parentId = idMap.get(clone.parentId as AnyNodeId) ?? clone.parentId
}
if ('children' in clone && Array.isArray(clone.children)) {
;(clone as Record<string, unknown>).children = (clone.children as AnyNodeId[])
.map((childId) => idMap.get(childId))
.filter((childId): childId is AnyNodeId => !!childId)
}
if ('wallId' in clone && typeof clone.wallId === 'string') {
const nextWallId = idMap.get(clone.wallId as AnyNodeId)
if (nextWallId) {
;(clone as Record<string, unknown>).wallId = nextWallId
} else {
delete (clone as Record<string, unknown>).wallId
}
}
if (clone.type === 'stair') {
const nextLevelId = getNextLevelId(targetLevel, nodes)
;(clone as StairNode).fromLevelId = targetLevel.id
;(clone as StairNode).toLevelId = nextLevelId
}
const metadata =
clone.metadata && typeof clone.metadata === 'object' && !Array.isArray(clone.metadata)
? { ...(clone.metadata as Record<string, unknown>) }
: {}
delete metadata.isNew
delete metadata.isTransient
;(clone as Record<string, unknown>).metadata = metadata
return AnyNode.parse(clone)
}
export function copySelectedNodesToEditorClipboard(selectedIds?: AnyNodeId[]) {
const scene = useScene.getState()
const ids = selectedIds ?? (useViewer.getState().selection.selectedIds as AnyNodeId[])
const selectedIdSet = new Set(ids)
const rootIds = ids.filter((id) => {
const node = scene.nodes[id]
return (
node &&
COPYABLE_ROOT_TYPES.has(node.type) &&
isLevelChildRoot(scene.nodes, node) &&
!hasSelectedAncestor(scene.nodes, id, selectedIdSet)
)
})
if (rootIds.length === 0) {
return false
}
const subtreeIds = new Set<AnyNodeId>()
for (const rootId of rootIds) {
collectSubtreeIds(scene.nodes, rootId, subtreeIds)
}
clipboardPayload = {
copiedAt: Date.now(),
nodes: [...subtreeIds]
.map((id) => scene.nodes[id])
.filter((node): node is AnyNode => !!node)
.map((node) => JSON.parse(JSON.stringify(node)) as AnyNode),
rootIds,
}
notifySubscribers()
return true
}
export function pasteEditorClipboardToLevel(targetLevelId?: AnyNodeId): PasteResult | null {
const payload = clipboardPayload
const targetLevel = getPasteTargetLevel(targetLevelId)
if (!payload || !targetLevel) return null
const scene = useScene.getState()
const idMap = new Map<AnyNodeId, AnyNodeId>()
for (const node of payload.nodes) {
idMap.set(node.id as AnyNodeId, generateId(extractIdPrefix(node.id)) as AnyNodeId)
}
const rootIdSet = new Set(payload.rootIds)
const pastedNodes: AnyNode[] = []
const skippedIds: AnyNodeId[] = []
for (const node of payload.nodes) {
try {
pastedNodes.push(
remapNodeReferences(node, node.id as AnyNodeId, targetLevel, idMap, rootIdSet, scene.nodes),
)
} catch (error) {
console.error('Failed to paste copied node', node.id, error)
skippedIds.push(node.id as AnyNodeId)
}
}
if (pastedNodes.length === 0) {
return { pastedIds: [], skippedIds }
}
scene.createNodes(
pastedNodes.map((node) => ({
node,
parentId: (node.parentId as AnyNodeId | null) ?? undefined,
})),
)
const pastedNodeIds = new Set(pastedNodes.map((node) => node.id as AnyNodeId))
const pastedRootIds = payload.rootIds
.map((rootId) => idMap.get(rootId))
.filter((id): id is AnyNodeId => !!id && pastedNodeIds.has(id))
useViewer.getState().setSelection({
levelId: targetLevel.id,
selectedIds: pastedRootIds,
})
return {
pastedIds: pastedRootIds,
skippedIds,
}
}
@@ -181,23 +181,55 @@ function FlatEndedBeam({
[3, 7, 4, 0],
];
const positions: number[] = [];
const pushTriangle = (a: number, b: number, c: number) => {
const uvs: number[] = [];
const pushVertex = (vertexIndex: number, uv: [number, number]) => {
const vertex = vertices[vertexIndex];
if (!vertex) return false;
positions.push(...vertex);
uvs.push(...uv);
return true;
};
const pushTriangle = (
a: number,
b: number,
c: number,
uvA: [number, number],
uvB: [number, number],
uvC: [number, number],
) => {
const va = vertices[a];
const vb = vertices[b];
const vc = vertices[c];
if (!va || !vb || !vc) return;
positions.push(...va, ...vb, ...vc);
pushVertex(a, uvA);
pushVertex(b, uvB);
pushVertex(c, uvC);
};
for (const [a, b, c, d] of faceQuads) {
pushTriangle(a, b, c);
pushTriangle(a, c, d);
pushTriangle(a, c, b);
pushTriangle(a, d, c);
const va = vertices[a];
const vb = vertices[b];
const vc = vertices[c];
const vd = vertices[d];
if (!va || !vb || !vc || !vd) continue;
const edgeU = Math.hypot(vb[0] - va[0], vb[1] - va[1], vb[2] - va[2]);
const edgeV = Math.hypot(vd[0] - va[0], vd[1] - va[1], vd[2] - va[2]);
const uvA: [number, number] = [0, 0];
const uvB: [number, number] = [edgeU, 0];
const uvC: [number, number] = [edgeU, edgeV];
const uvD: [number, number] = [0, edgeV];
pushTriangle(a, b, c, uvA, uvB, uvC);
pushTriangle(a, c, d, uvA, uvC, uvD);
pushTriangle(a, c, b, uvA, uvC, uvB);
pushTriangle(a, d, c, uvA, uvD, uvC);
}
const geometry = new BufferGeometry();
geometry.setAttribute("position", new Float32BufferAttribute(positions, 3));
geometry.setAttribute("uv", new Float32BufferAttribute(uvs, 2));
geometry.setAttribute("uv2", new Float32BufferAttribute(uvs.slice(), 2));
geometry.computeVertexNormals();
return geometry;
}, [depth, length, start, end, width]);
@@ -1,11 +1,12 @@
import { useFrame } from '@react-three/fiber'
import {
type AnyNodeId,
getRenderableSlabPolygon,
sceneRegistry,
type SlabNode,
sceneRegistry,
useScene,
} from '@pascal-app/core'
import { useFrame } from '@react-three/fiber'
import { useEffect } from 'react'
import * as THREE from 'three'
function ensureUv2Attribute(geometry: THREE.BufferGeometry) {
@@ -22,6 +23,16 @@ function ensureUv2Attribute(geometry: THREE.BufferGeometry) {
export const SlabSystem = () => {
const dirtyNodes = useScene((state) => state.dirtyNodes)
const clearDirty = useScene((state) => state.clearDirty)
const markDirty = useScene((state) => state.markDirty)
useEffect(() => {
const nodes = useScene.getState().nodes
for (const node of Object.values(nodes)) {
if (node.type === 'slab') {
markDirty(node.id)
}
}
}, [markDirty])
useFrame(() => {
if (dirtyNodes.size === 0) return