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