Add column hit testing and align column defaults
This commit is contained in:
@@ -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])
|
||||
}
|
||||
} else {
|
||||
candidates.push(projectPointOntoSegment(point, fence))
|
||||
for (const candidate of [fence.start, fence.end]) {
|
||||
const candidateDistanceSquared = distanceSquared(point, candidate)
|
||||
if (
|
||||
candidateDistanceSquared > cornerRadiusSquared ||
|
||||
candidateDistanceSquared >= bestCornerDistanceSquared
|
||||
) {
|
||||
continue
|
||||
}
|
||||
|
||||
for (const candidate of candidates) {
|
||||
bestCornerTarget = candidate
|
||||
bestCornerDistanceSquared = candidateDistanceSquared
|
||||
}
|
||||
|
||||
if (isCurvedWall(fence)) {
|
||||
const sampleCount = Math.max(8, Math.ceil(getWallCurveLength(fence) / 0.3))
|
||||
for (let index = 1; index < sampleCount; index += 1) {
|
||||
const frame = getWallCurveFrameAt(fence, index / sampleCount)
|
||||
const candidate: FencePlanPoint = [frame.point.x, frame.point.y]
|
||||
const candidateDistanceSquared = distanceSquared(point, candidate)
|
||||
if (
|
||||
candidateDistanceSquared > spanRadiusSquared ||
|
||||
candidateDistanceSquared >= bestSpanDistanceSquared
|
||||
) {
|
||||
continue
|
||||
}
|
||||
|
||||
bestSpanTarget = candidate
|
||||
bestSpanDistanceSquared = candidateDistanceSquared
|
||||
}
|
||||
} else {
|
||||
const candidate = projectPointOntoSegment(point, fence)
|
||||
if (!candidate) {
|
||||
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(
|
||||
|
||||
@@ -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,
|
||||
]),
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user