Add auto ceilings and simplify ceiling selection affordances
This commit is contained in:
@@ -3,8 +3,8 @@ import {
|
|||||||
getWallCurveFrameAt,
|
getWallCurveFrameAt,
|
||||||
isCurvedWall,
|
isCurvedWall,
|
||||||
} from '../systems/wall/wall-curve'
|
} from '../systems/wall/wall-curve'
|
||||||
|
import { CeilingNode, SlabNode, type CeilingNode as CeilingNodeType, type SlabNode as SlabNodeType, type WallNode } from '../schema'
|
||||||
import { simplifyClosedPolygon } from './polygon-geometry'
|
import { simplifyClosedPolygon } from './polygon-geometry'
|
||||||
import { SlabNode, type SlabNode as SlabNodeType, type WallNode } from '../schema'
|
|
||||||
|
|
||||||
type Point2D = { x: number; y: number }
|
type Point2D = { x: number; y: number }
|
||||||
|
|
||||||
@@ -31,6 +31,7 @@ type DetectedRoom = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_AUTO_SLAB_ELEVATION = 0.05
|
const DEFAULT_AUTO_SLAB_ELEVATION = 0.05
|
||||||
|
const DEFAULT_AUTO_CEILING_HEIGHT = 2.5
|
||||||
const ROOM_CURVE_TOLERANCE = 0.04
|
const ROOM_CURVE_TOLERANCE = 0.04
|
||||||
const MAX_CURVE_SUBDIVISION_DEPTH = 6
|
const MAX_CURVE_SUBDIVISION_DEPTH = 6
|
||||||
const AUTO_SLAB_POLYGON_SIMPLIFY_TOLERANCE = 0.08
|
const AUTO_SLAB_POLYGON_SIMPLIFY_TOLERANCE = 0.08
|
||||||
@@ -417,14 +418,15 @@ export function resolveWallSurfaceSides(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function nextAutoRoomName(
|
function nextAutoRoomName(
|
||||||
slabs: Array<{
|
nodes: Array<{
|
||||||
name?: string
|
name?: string
|
||||||
}>,
|
}>,
|
||||||
|
suffix: 'Slab' | 'Ceiling',
|
||||||
) {
|
) {
|
||||||
let maxIndex = 0
|
let maxIndex = 0
|
||||||
|
|
||||||
for (const slab of slabs) {
|
for (const node of nodes) {
|
||||||
const match = /^Room\s+(\d+)$/.exec((slab.name ?? '').trim())
|
const match = /^Room\s+(\d+)(?:\s+(?:Slab|Ceiling))?$/i.exec((node.name ?? '').trim())
|
||||||
if (!match) continue
|
if (!match) continue
|
||||||
const index = Number(match[1])
|
const index = Number(match[1])
|
||||||
if (Number.isFinite(index)) {
|
if (Number.isFinite(index)) {
|
||||||
@@ -432,7 +434,17 @@ function nextAutoRoomName(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return `Room ${maxIndex + 1}`
|
return `Room ${maxIndex + 1} ${suffix}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameTuplePolygon(
|
||||||
|
current: Array<[number, number]>,
|
||||||
|
next: Array<[number, number]>,
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
current.length === next.length &&
|
||||||
|
current.every((point, index) => point[0] === next[index]?.[0] && point[1] === next[index]?.[1])
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function wallGeometrySignature(wall: WallNode) {
|
function wallGeometrySignature(wall: WallNode) {
|
||||||
@@ -567,14 +579,7 @@ function syncAutoSlabsForLevel(
|
|||||||
const polygon = updatesById.get(slab.id)
|
const polygon = updatesById.get(slab.id)
|
||||||
if (!polygon) return []
|
if (!polygon) return []
|
||||||
|
|
||||||
const samePolygon =
|
return sameTuplePolygon(slab.polygon, polygon) ? [] : [{ id: slab.id, data: { polygon } }]
|
||||||
slab.polygon.length === polygon.length &&
|
|
||||||
slab.polygon.every((point, index) => {
|
|
||||||
const nextPoint = polygon[index]
|
|
||||||
return point[0] === nextPoint?.[0] && point[1] === nextPoint?.[1]
|
|
||||||
})
|
|
||||||
|
|
||||||
return samePolygon ? [] : [{ id: slab.id, data: { polygon } }]
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const plannedSlabsForNaming: Array<{ name?: string }> = [...existingSlabs]
|
const plannedSlabsForNaming: Array<{ name?: string }> = [...existingSlabs]
|
||||||
@@ -585,7 +590,7 @@ function syncAutoSlabsForLevel(
|
|||||||
const room = detected[index]
|
const room = detected[index]
|
||||||
if (!room) continue
|
if (!room) continue
|
||||||
|
|
||||||
const name = nextAutoRoomName(plannedSlabsForNaming)
|
const name = nextAutoRoomName(plannedSlabsForNaming, 'Slab')
|
||||||
plannedSlabsForNaming.push({ name })
|
plannedSlabsForNaming.push({ name })
|
||||||
|
|
||||||
slabsToCreate.push(
|
slabsToCreate.push(
|
||||||
@@ -612,6 +617,149 @@ function syncAutoSlabsForLevel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function syncAutoCeilingsForLevel(
|
||||||
|
levelId: string,
|
||||||
|
roomPolygons: Point2D[][],
|
||||||
|
existingCeilings: CeilingNodeType[],
|
||||||
|
sceneStore: any,
|
||||||
|
) {
|
||||||
|
const manualCeilings = existingCeilings.filter((ceiling) => !ceiling.autoFromWalls)
|
||||||
|
const manualSignatures = new Set(
|
||||||
|
manualCeilings.map((ceiling) => polygonSignature(ceiling.polygon.map(pointFromTuple))),
|
||||||
|
)
|
||||||
|
|
||||||
|
const detected: DetectedRoom[] = roomPolygons
|
||||||
|
.map((poly) => ({
|
||||||
|
poly: simplifyClosedPolygon(poly.map(pointToTuple), AUTO_SLAB_POLYGON_SIMPLIFY_TOLERANCE).map(
|
||||||
|
pointFromTuple,
|
||||||
|
),
|
||||||
|
sig: '',
|
||||||
|
centroid: { x: 0, y: 0 },
|
||||||
|
area: 0,
|
||||||
|
bbox: bboxOf([]),
|
||||||
|
}))
|
||||||
|
.map((room) => ({
|
||||||
|
...room,
|
||||||
|
sig: polygonSignature(room.poly),
|
||||||
|
centroid: polygonCentroid(room.poly),
|
||||||
|
area: Math.abs(polygonArea(room.poly)),
|
||||||
|
bbox: bboxOf(room.poly),
|
||||||
|
}))
|
||||||
|
.filter(({ sig }) => !manualSignatures.has(sig))
|
||||||
|
|
||||||
|
const existingAuto = existingCeilings.filter((ceiling) => ceiling.autoFromWalls)
|
||||||
|
const existingAutoMeta = existingAuto.map((ceiling) => {
|
||||||
|
const poly = ceiling.polygon.map(pointFromTuple)
|
||||||
|
return {
|
||||||
|
ceiling,
|
||||||
|
sig: polygonSignature(poly),
|
||||||
|
centroid: polygonCentroid(poly),
|
||||||
|
area: Math.abs(polygonArea(poly)),
|
||||||
|
bbox: bboxOf(poly),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const matchedCeilingIds = new Set<string>()
|
||||||
|
const matchedDetectedIdx = new Set<number>()
|
||||||
|
const updatesById = new Map<string, [number, number][]>()
|
||||||
|
|
||||||
|
const autoBySignature = new Map<string, (typeof existingAutoMeta)[number]>()
|
||||||
|
for (const entry of existingAutoMeta) {
|
||||||
|
autoBySignature.set(entry.sig, entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
detected.forEach((room, index) => {
|
||||||
|
const existing = autoBySignature.get(room.sig)
|
||||||
|
if (!existing) return
|
||||||
|
|
||||||
|
matchedDetectedIdx.add(index)
|
||||||
|
matchedCeilingIds.add(existing.ceiling.id)
|
||||||
|
updatesById.set(existing.ceiling.id, room.poly.map(pointToTuple))
|
||||||
|
})
|
||||||
|
|
||||||
|
const remainingDetected = detected
|
||||||
|
.map((room, index) => ({ room, index }))
|
||||||
|
.filter(({ index }) => !matchedDetectedIdx.has(index))
|
||||||
|
.sort((a, b) => b.room.area - a.room.area)
|
||||||
|
|
||||||
|
const remainingAuto = existingAutoMeta.filter((entry) => !matchedCeilingIds.has(entry.ceiling.id))
|
||||||
|
|
||||||
|
for (const { room, index } of remainingDetected) {
|
||||||
|
let bestMatch: { entry: (typeof remainingAuto)[number]; score: number } | null = null
|
||||||
|
|
||||||
|
for (const entry of remainingAuto) {
|
||||||
|
if (matchedCeilingIds.has(entry.ceiling.id)) continue
|
||||||
|
|
||||||
|
const dx = room.centroid.x - entry.centroid.x
|
||||||
|
const dy = room.centroid.y - entry.centroid.y
|
||||||
|
const dist = Math.hypot(dx, dy)
|
||||||
|
const areaRatio = entry.area > 1e-6 ? room.area / entry.area : 999
|
||||||
|
const areaPenalty = Math.abs(Math.log(Math.max(1e-6, areaRatio)))
|
||||||
|
const overlap = bboxOverlapArea(room.bbox, entry.bbox)
|
||||||
|
|
||||||
|
if (overlap <= 0.0001 && dist > 1.5) continue
|
||||||
|
|
||||||
|
const score = dist + areaPenalty * 0.35
|
||||||
|
if (!bestMatch || score < bestMatch.score) {
|
||||||
|
bestMatch = { entry, score }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!bestMatch) continue
|
||||||
|
|
||||||
|
matchedDetectedIdx.add(index)
|
||||||
|
matchedCeilingIds.add(bestMatch.entry.ceiling.id)
|
||||||
|
updatesById.set(bestMatch.entry.ceiling.id, room.poly.map(pointToTuple))
|
||||||
|
}
|
||||||
|
|
||||||
|
const ceilingsToDelete = existingAuto
|
||||||
|
.filter((ceiling) => !updatesById.has(ceiling.id))
|
||||||
|
.map((ceiling) => ceiling.id)
|
||||||
|
|
||||||
|
const ceilingsToUpdate = existingAuto
|
||||||
|
.filter((ceiling) => updatesById.has(ceiling.id))
|
||||||
|
.flatMap((ceiling) => {
|
||||||
|
const polygon = updatesById.get(ceiling.id)
|
||||||
|
if (!polygon) return []
|
||||||
|
|
||||||
|
return sameTuplePolygon(ceiling.polygon, polygon) ? [] : [{ id: ceiling.id, data: { polygon } }]
|
||||||
|
})
|
||||||
|
|
||||||
|
const plannedCeilingsForNaming: Array<{ name?: string }> = [...existingCeilings]
|
||||||
|
const ceilingsToCreate: CeilingNodeType[] = []
|
||||||
|
for (let index = 0; index < detected.length; index += 1) {
|
||||||
|
if (matchedDetectedIdx.has(index)) continue
|
||||||
|
|
||||||
|
const room = detected[index]
|
||||||
|
if (!room) continue
|
||||||
|
|
||||||
|
const name = nextAutoRoomName(plannedCeilingsForNaming, 'Ceiling')
|
||||||
|
plannedCeilingsForNaming.push({ name })
|
||||||
|
|
||||||
|
ceilingsToCreate.push(
|
||||||
|
CeilingNode.parse({
|
||||||
|
name,
|
||||||
|
polygon: room.poly.map(pointToTuple),
|
||||||
|
holes: [],
|
||||||
|
height: DEFAULT_AUTO_CEILING_HEIGHT,
|
||||||
|
autoFromWalls: true,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ceilingsToDelete.length > 0) {
|
||||||
|
sceneStore.getState().deleteNodes(ceilingsToDelete)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ceilingsToUpdate.length > 0) {
|
||||||
|
sceneStore.getState().updateNodes(ceilingsToUpdate)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ceilingsToCreate.length > 0) {
|
||||||
|
sceneStore.getState().createNodes(ceilingsToCreate.map((node) => ({ node, parentId: levelId })))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function detectSpacesFromWalls(levelId: string, walls: WallNode[]) {
|
function detectSpacesFromWalls(levelId: string, walls: WallNode[]) {
|
||||||
const roomPolygons = extractRoomPolygons(walls)
|
const roomPolygons = extractRoomPolygons(walls)
|
||||||
const wallUpdates: WallSideUpdate[] = walls.map((wall) => ({
|
const wallUpdates: WallSideUpdate[] = walls.map((wall) => ({
|
||||||
@@ -657,6 +805,9 @@ function runSpaceDetection(
|
|||||||
const slabs = Object.values(nodes).filter(
|
const slabs = Object.values(nodes).filter(
|
||||||
(node: any) => node?.type === 'slab' && node.parentId === levelId,
|
(node: any) => node?.type === 'slab' && node.parentId === levelId,
|
||||||
)
|
)
|
||||||
|
const ceilings = Object.values(nodes).filter(
|
||||||
|
(node: any) => node?.type === 'ceiling' && node.parentId === levelId,
|
||||||
|
)
|
||||||
|
|
||||||
const { wallUpdates, spaces, roomPolygons } = detectSpacesFromWalls(levelId, walls)
|
const { wallUpdates, spaces, roomPolygons } = detectSpacesFromWalls(levelId, walls)
|
||||||
|
|
||||||
@@ -683,6 +834,12 @@ function runSpaceDetection(
|
|||||||
slabs.map((slab: any) => SlabNode.parse(slab)),
|
slabs.map((slab: any) => SlabNode.parse(slab)),
|
||||||
sceneStore,
|
sceneStore,
|
||||||
)
|
)
|
||||||
|
syncAutoCeilingsForLevel(
|
||||||
|
levelId,
|
||||||
|
roomPolygons,
|
||||||
|
ceilings.map((ceiling: any) => CeilingNode.parse(ceiling)),
|
||||||
|
sceneStore,
|
||||||
|
)
|
||||||
|
|
||||||
for (const space of spaces) {
|
for (const space of spaces) {
|
||||||
nextSpaces[space.id] = space
|
nextSpaces[space.id] = space
|
||||||
|
|||||||
@@ -13,11 +13,13 @@ export const CeilingNode = BaseNode.extend({
|
|||||||
polygon: z.array(z.tuple([z.number(), z.number()])),
|
polygon: z.array(z.tuple([z.number(), z.number()])),
|
||||||
holes: z.array(z.array(z.tuple([z.number(), z.number()]))).default([]),
|
holes: z.array(z.array(z.tuple([z.number(), z.number()]))).default([]),
|
||||||
height: z.number().default(2.5), // Height in meters
|
height: z.number().default(2.5), // Height in meters
|
||||||
|
autoFromWalls: z.boolean().default(false),
|
||||||
}).describe(
|
}).describe(
|
||||||
dedent`
|
dedent`
|
||||||
Ceiling node - used to represent a ceiling in the building
|
Ceiling node - used to represent a ceiling in the building
|
||||||
- polygon: array of [x, z] points defining the ceiling boundary
|
- polygon: array of [x, z] points defining the ceiling boundary
|
||||||
- holes: array of polygons representing holes in the ceiling
|
- holes: array of polygons representing holes in the ceiling
|
||||||
|
- autoFromWalls: whether the ceiling is automatically generated from a closed wall loop
|
||||||
`,
|
`,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+83
-44
@@ -2,30 +2,30 @@
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
type CeilingNode,
|
type CeilingNode,
|
||||||
|
emitter,
|
||||||
resolveLevelId,
|
resolveLevelId,
|
||||||
sceneRegistry,
|
sceneRegistry,
|
||||||
useScene,
|
useScene,
|
||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { createPortal, type ThreeEvent } from '@react-three/fiber'
|
import { createPortal, type ThreeEvent } from '@react-three/fiber'
|
||||||
import { useMemo } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import type { Object3D } from 'three'
|
||||||
import { useShallow } from 'zustand/react/shallow'
|
import { useShallow } from 'zustand/react/shallow'
|
||||||
import useEditor from '../../../store/use-editor'
|
import useEditor from '../../../store/use-editor'
|
||||||
|
|
||||||
const BRACKET_THICKNESS = 0.04
|
const BRACKET_THICKNESS = 0.04
|
||||||
const BRACKET_HEIGHT = 0.04
|
const BRACKET_HEIGHT = 0.04
|
||||||
const BRACKET_Y_OFFSET = 0.035
|
const BRACKET_Y_OFFSET = 0.035
|
||||||
const CORNER_BLOCK_SIZE = 0.085
|
const HIT_BOX_SIZE: [number, number, number] = [0.28, 0.08, 0.28]
|
||||||
const HIT_BOX_SIZE: [number, number, number] = [0.24, 0.12, 0.24]
|
|
||||||
const HIT_INSET = 0.16
|
|
||||||
|
|
||||||
type CornerBracketData = {
|
type CornerBracketData = {
|
||||||
corner: [number, number]
|
corner: [number, number]
|
||||||
hitCenter: [number, number]
|
|
||||||
incomingDirection: [number, number]
|
incomingDirection: [number, number]
|
||||||
outgoingDirection: [number, number]
|
outgoingDirection: [number, number]
|
||||||
incomingLength: number
|
incomingLength: number
|
||||||
outgoingLength: number
|
outgoingLength: number
|
||||||
|
cornerStrength: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CeilingSelectionAffordanceSystem = () => {
|
export const CeilingSelectionAffordanceSystem = () => {
|
||||||
@@ -75,13 +75,37 @@ const CeilingSelectionAffordance = ({
|
|||||||
ceiling: CeilingNode
|
ceiling: CeilingNode
|
||||||
levelId: string
|
levelId: string
|
||||||
}) => {
|
}) => {
|
||||||
const selectedIds = useViewer((state) => state.selection.selectedIds)
|
const [levelObject, setLevelObject] = useState<Object3D | null>(() => sceneRegistry.nodes.get(levelId) ?? null)
|
||||||
const isSelected = selectedIds.includes(ceiling.id)
|
|
||||||
const levelObject = sceneRegistry.nodes.get(levelId)
|
|
||||||
|
|
||||||
const corners = useMemo(() => buildCornerBrackets(ceiling.polygon), [ceiling.polygon])
|
const corners = useMemo(() => buildCornerBrackets(ceiling.polygon), [ceiling.polygon])
|
||||||
|
|
||||||
if (!levelObject || corners.length === 0 || isSelected) return null
|
useEffect(() => {
|
||||||
|
let frameId = 0
|
||||||
|
|
||||||
|
const resolveLevelObject = () => {
|
||||||
|
const nextLevelObject = sceneRegistry.nodes.get(levelId) ?? null
|
||||||
|
setLevelObject((currentLevelObject) => {
|
||||||
|
if (currentLevelObject === nextLevelObject) {
|
||||||
|
return currentLevelObject
|
||||||
|
}
|
||||||
|
return nextLevelObject
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!nextLevelObject) {
|
||||||
|
frameId = window.requestAnimationFrame(resolveLevelObject)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resolveLevelObject()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (frameId) {
|
||||||
|
window.cancelAnimationFrame(frameId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [levelId])
|
||||||
|
|
||||||
|
if (!levelObject || corners.length === 0) return null
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<group position={[0, (ceiling.height ?? 2.5) + BRACKET_Y_OFFSET, 0]}>
|
<group position={[0, (ceiling.height ?? 2.5) + BRACKET_Y_OFFSET, 0]}>
|
||||||
@@ -104,17 +128,16 @@ const CornerBracket = ({
|
|||||||
ceiling: CeilingNode
|
ceiling: CeilingNode
|
||||||
corner: CornerBracketData
|
corner: CornerBracketData
|
||||||
}) => {
|
}) => {
|
||||||
|
const [isHovered, setIsHovered] = useState(false)
|
||||||
const color = '#d4d4d4'
|
const color = '#d4d4d4'
|
||||||
const opacity = 0.72
|
const opacity = 0.72
|
||||||
|
const cubeColor = isHovered ? '#818cf8' : '#d4d4d4'
|
||||||
|
const cubeOpacity = isHovered ? 0.92 : 0.72
|
||||||
|
|
||||||
const handleClick = (e: ThreeEvent<PointerEvent>) => {
|
const handleClick = (e: ThreeEvent<MouseEvent>) => {
|
||||||
if (e.button !== 0) return
|
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
|
|
||||||
const nodes = useScene.getState().nodes
|
const nodes = useScene.getState().nodes
|
||||||
const selection = useViewer.getState().selection
|
|
||||||
const levelId = resolveLevelId(ceiling, nodes)
|
|
||||||
const buildingId = findBuildingId(levelId, nodes)
|
|
||||||
|
|
||||||
useEditor.getState().setMovingNode(null)
|
useEditor.getState().setMovingNode(null)
|
||||||
useEditor.getState().setMovingWallEndpoint(null)
|
useEditor.getState().setMovingWallEndpoint(null)
|
||||||
@@ -122,39 +145,45 @@ const CornerBracket = ({
|
|||||||
useEditor.getState().setEditingHole(null)
|
useEditor.getState().setEditingHole(null)
|
||||||
useEditor.getState().setMode('select')
|
useEditor.getState().setMode('select')
|
||||||
|
|
||||||
useViewer.getState().setSelection({
|
emitter.emit('ceiling:click' as any, {
|
||||||
buildingId: buildingId ?? selection.buildingId,
|
node: ceiling,
|
||||||
levelId,
|
nativeEvent: e.nativeEvent,
|
||||||
selectedIds: [ceiling.id],
|
localPosition: [0, 0, 0],
|
||||||
|
position: [corner.corner[0], ceiling.height ?? 2.5, corner.corner[1]],
|
||||||
|
stopPropagation: () => e.stopPropagation(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<group position={[corner.corner[0], 0, corner.corner[1]]}>
|
<group position={[corner.corner[0], 0, corner.corner[1]]}>
|
||||||
<mesh>
|
|
||||||
<boxGeometry args={[CORNER_BLOCK_SIZE, BRACKET_HEIGHT, CORNER_BLOCK_SIZE]} />
|
|
||||||
<meshBasicMaterial color={color} depthWrite={false} opacity={opacity} transparent />
|
|
||||||
</mesh>
|
|
||||||
|
|
||||||
<BracketLeg
|
<BracketLeg
|
||||||
color={color}
|
color={color}
|
||||||
direction={corner.incomingDirection}
|
direction={corner.incomingDirection}
|
||||||
length={corner.incomingLength}
|
length={corner.incomingLength}
|
||||||
|
onClick={handleClick}
|
||||||
opacity={opacity}
|
opacity={opacity}
|
||||||
/>
|
/>
|
||||||
<BracketLeg
|
<BracketLeg
|
||||||
color={color}
|
color={color}
|
||||||
direction={corner.outgoingDirection}
|
direction={corner.outgoingDirection}
|
||||||
length={corner.outgoingLength}
|
length={corner.outgoingLength}
|
||||||
|
onClick={handleClick}
|
||||||
opacity={opacity}
|
opacity={opacity}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<mesh
|
<mesh
|
||||||
onPointerDown={handleClick}
|
onClick={handleClick}
|
||||||
position={[corner.hitCenter[0] - corner.corner[0], 0, corner.hitCenter[1] - corner.corner[1]]}
|
onPointerEnter={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setIsHovered(true)
|
||||||
|
}}
|
||||||
|
onPointerLeave={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setIsHovered(false)
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<boxGeometry args={HIT_BOX_SIZE} />
|
<boxGeometry args={HIT_BOX_SIZE} />
|
||||||
<meshBasicMaterial opacity={0} transparent />
|
<meshBasicMaterial color={cubeColor} depthWrite={false} opacity={cubeOpacity} transparent />
|
||||||
</mesh>
|
</mesh>
|
||||||
</group>
|
</group>
|
||||||
)
|
)
|
||||||
@@ -164,11 +193,13 @@ const BracketLeg = ({
|
|||||||
direction,
|
direction,
|
||||||
length,
|
length,
|
||||||
color,
|
color,
|
||||||
|
onClick,
|
||||||
opacity,
|
opacity,
|
||||||
}: {
|
}: {
|
||||||
direction: [number, number]
|
direction: [number, number]
|
||||||
length: number
|
length: number
|
||||||
color: string
|
color: string
|
||||||
|
onClick: (e: ThreeEvent<MouseEvent>) => void
|
||||||
opacity: number
|
opacity: number
|
||||||
}) => {
|
}) => {
|
||||||
const angle = Math.atan2(direction[1], direction[0])
|
const angle = Math.atan2(direction[1], direction[0])
|
||||||
@@ -179,7 +210,11 @@ const BracketLeg = ({
|
|||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<mesh position={position} rotation={[0, angle, 0]}>
|
<mesh
|
||||||
|
onClick={onClick}
|
||||||
|
position={position}
|
||||||
|
rotation={[0, angle, 0]}
|
||||||
|
>
|
||||||
<boxGeometry args={[length, BRACKET_HEIGHT, BRACKET_THICKNESS]} />
|
<boxGeometry args={[length, BRACKET_HEIGHT, BRACKET_THICKNESS]} />
|
||||||
<meshBasicMaterial color={color} depthWrite={false} opacity={opacity} transparent />
|
<meshBasicMaterial color={color} depthWrite={false} opacity={opacity} transparent />
|
||||||
</mesh>
|
</mesh>
|
||||||
@@ -189,31 +224,41 @@ const BracketLeg = ({
|
|||||||
function buildCornerBrackets(polygon: Array<[number, number]>): CornerBracketData[] {
|
function buildCornerBrackets(polygon: Array<[number, number]>): CornerBracketData[] {
|
||||||
if (polygon.length < 3) return []
|
if (polygon.length < 3) return []
|
||||||
|
|
||||||
return polygon.map((corner, index) => {
|
const allCorners = polygon.map((corner, index) => {
|
||||||
const previous = polygon[(index - 1 + polygon.length) % polygon.length]!
|
const previous = polygon[(index - 1 + polygon.length) % polygon.length]!
|
||||||
const next = polygon[(index + 1) % polygon.length]!
|
const next = polygon[(index + 1) % polygon.length]!
|
||||||
const incomingVector = [previous[0] - corner[0], previous[1] - corner[1]] as [number, number]
|
const incomingVector = [previous[0] - corner[0], previous[1] - corner[1]] as [number, number]
|
||||||
const outgoingVector = [next[0] - corner[0], next[1] - corner[1]] as [number, number]
|
const outgoingVector = [next[0] - corner[0], next[1] - corner[1]] as [number, number]
|
||||||
|
const incomingDirection = normalize2D(incomingVector)
|
||||||
|
const outgoingDirection = normalize2D(outgoingVector)
|
||||||
|
|
||||||
const incomingLength = Math.hypot(incomingVector[0], incomingVector[1])
|
const incomingLength = Math.hypot(incomingVector[0], incomingVector[1])
|
||||||
const outgoingLength = Math.hypot(outgoingVector[0], outgoingVector[1])
|
const outgoingLength = Math.hypot(outgoingVector[0], outgoingVector[1])
|
||||||
const insetDirection = normalize2D([
|
const cornerStrength = 1 - Math.abs(incomingDirection[0] * outgoingDirection[0] + incomingDirection[1] * outgoingDirection[1])
|
||||||
normalize2D(incomingVector)[0] + normalize2D(outgoingVector)[0],
|
|
||||||
normalize2D(incomingVector)[1] + normalize2D(outgoingVector)[1],
|
|
||||||
])
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
corner,
|
corner,
|
||||||
hitCenter: [
|
incomingDirection,
|
||||||
corner[0] + insetDirection[0] * HIT_INSET,
|
outgoingDirection,
|
||||||
corner[1] + insetDirection[1] * HIT_INSET,
|
|
||||||
],
|
|
||||||
incomingDirection: normalize2D(incomingVector),
|
|
||||||
outgoingDirection: normalize2D(outgoingVector),
|
|
||||||
incomingLength: getBracketLength(incomingLength),
|
incomingLength: getBracketLength(incomingLength),
|
||||||
outgoingLength: getBracketLength(outgoingLength),
|
outgoingLength: getBracketLength(outgoingLength),
|
||||||
|
cornerStrength,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (allCorners.length <= 4) {
|
||||||
|
return allCorners
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedIndices = new Set(
|
||||||
|
allCorners
|
||||||
|
.map((corner, index) => ({ index, strength: corner.cornerStrength }))
|
||||||
|
.sort((a, b) => b.strength - a.strength)
|
||||||
|
.slice(0, 4)
|
||||||
|
.map(({ index }) => index),
|
||||||
|
)
|
||||||
|
|
||||||
|
return allCorners.filter((_, index) => selectedIndices.has(index))
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalize2D(vector: [number, number]): [number, number] {
|
function normalize2D(vector: [number, number]): [number, number] {
|
||||||
@@ -225,9 +270,3 @@ function normalize2D(vector: [number, number]): [number, number] {
|
|||||||
function getBracketLength(edgeLength: number): number {
|
function getBracketLength(edgeLength: number): number {
|
||||||
return Math.max(0.14, Math.min(0.38, edgeLength * 0.22))
|
return Math.max(0.14, Math.min(0.38, edgeLength * 0.22))
|
||||||
}
|
}
|
||||||
|
|
||||||
function findBuildingId(levelId: string | null, nodes: Record<string, { parentId: string | null }>): string | null {
|
|
||||||
if (!levelId) return null
|
|
||||||
const level = nodes[levelId]
|
|
||||||
return level?.parentId ?? null
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { emitter, type GridEvent, sceneRegistry } from '@pascal-app/core'
|
import { emitter, type GridEvent, sceneRegistry } from '@pascal-app/core'
|
||||||
import { createPortal } from '@react-three/fiber'
|
import { createPortal } from '@react-three/fiber'
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { BufferGeometry, Float32BufferAttribute, type Line } from 'three'
|
import { BufferGeometry, Float32BufferAttribute, type Line, type Object3D } from 'three'
|
||||||
import { EDITOR_LAYER } from '../../../lib/constants'
|
import { EDITOR_LAYER } from '../../../lib/constants'
|
||||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||||
|
|
||||||
@@ -44,8 +44,40 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
|||||||
surfaceHeight = 0,
|
surfaceHeight = 0,
|
||||||
allowPolygonMove = false,
|
allowPolygonMove = false,
|
||||||
}) => {
|
}) => {
|
||||||
// Get level node from registry if levelId is provided
|
const [levelNode, setLevelNode] = useState<Object3D | null>(() =>
|
||||||
const levelNode = levelId ? sceneRegistry.nodes.get(levelId) : null
|
levelId ? (sceneRegistry.nodes.get(levelId) ?? null) : null,
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!levelId) {
|
||||||
|
setLevelNode(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let frameId = 0
|
||||||
|
|
||||||
|
const resolveLevelNode = () => {
|
||||||
|
const nextLevelNode = sceneRegistry.nodes.get(levelId) ?? null
|
||||||
|
setLevelNode((currentLevelNode) => {
|
||||||
|
if (currentLevelNode === nextLevelNode) {
|
||||||
|
return currentLevelNode
|
||||||
|
}
|
||||||
|
return nextLevelNode
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!nextLevelNode) {
|
||||||
|
frameId = window.requestAnimationFrame(resolveLevelNode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resolveLevelNode()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (frameId) {
|
||||||
|
window.cancelAnimationFrame(frameId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [levelId])
|
||||||
|
|
||||||
// When using portal, edit at Y_OFFSET (local to level)
|
// When using portal, edit at Y_OFFSET (local to level)
|
||||||
// When not using portal, edit at world origin
|
// When not using portal, edit at world origin
|
||||||
|
|||||||
@@ -51,6 +51,9 @@ export const CurveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
|
|||||||
let wasCommitted = false
|
let wasCommitted = false
|
||||||
|
|
||||||
const applyPreview = (curveOffset: number) => {
|
const applyPreview = (curveOffset: number) => {
|
||||||
|
if (previewOffsetRef.current === curveOffset) {
|
||||||
|
return
|
||||||
|
}
|
||||||
previewOffsetRef.current = curveOffset
|
previewOffsetRef.current = curveOffset
|
||||||
|
|
||||||
const nextNode = {
|
const nextNode = {
|
||||||
@@ -64,6 +67,10 @@ export const CurveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const restoreOriginal = () => {
|
const restoreOriginal = () => {
|
||||||
|
if (previewOffsetRef.current === originalCurveOffset) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
previewOffsetRef.current = originalCurveOffset
|
||||||
useScene.getState().updateNode(nodeId, { curveOffset: originalCurveOffset })
|
useScene.getState().updateNode(nodeId, { curveOffset: originalCurveOffset })
|
||||||
useScene.getState().markDirty(nodeId as AnyNodeId)
|
useScene.getState().markDirty(nodeId as AnyNodeId)
|
||||||
}
|
}
|
||||||
@@ -100,8 +107,10 @@ export const CurveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
|
|||||||
const curveOffset = previewOffsetRef.current
|
const curveOffset = previewOffsetRef.current
|
||||||
wasCommitted = true
|
wasCommitted = true
|
||||||
useScene.temporal.getState().resume()
|
useScene.temporal.getState().resume()
|
||||||
useScene.getState().updateNode(nodeId, { curveOffset })
|
if (curveOffset !== getClampedWallCurveOffset(node)) {
|
||||||
useScene.getState().markDirty(nodeId as AnyNodeId)
|
useScene.getState().updateNode(nodeId, { curveOffset })
|
||||||
|
useScene.getState().markDirty(nodeId as AnyNodeId)
|
||||||
|
}
|
||||||
useScene.temporal.getState().pause()
|
useScene.temporal.getState().pause()
|
||||||
|
|
||||||
sfxEmitter.emit('sfx:item-place')
|
sfxEmitter.emit('sfx:item-place')
|
||||||
|
|||||||
Reference in New Issue
Block a user