From 4edf45f7c982daeca1ede1d9d193fc6dff5b509c Mon Sep 17 00:00:00 2001 From: sudhir Date: Thu, 16 Apr 2026 17:56:54 +0530 Subject: [PATCH] Add auto ceilings and simplify ceiling selection affordances --- packages/core/src/lib/space-detection.ts | 185 ++++++++++++++++-- packages/core/src/schema/nodes/ceiling.ts | 2 + .../ceiling-selection-affordance-system.tsx | 127 +++++++----- .../tools/shared/polygon-editor.tsx | 38 +++- .../components/tools/wall/curve-wall-tool.tsx | 13 +- 5 files changed, 302 insertions(+), 63 deletions(-) diff --git a/packages/core/src/lib/space-detection.ts b/packages/core/src/lib/space-detection.ts index beed6a99..9aaed406 100644 --- a/packages/core/src/lib/space-detection.ts +++ b/packages/core/src/lib/space-detection.ts @@ -3,8 +3,8 @@ import { getWallCurveFrameAt, isCurvedWall, } 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 { SlabNode, type SlabNode as SlabNodeType, type WallNode } from '../schema' type Point2D = { x: number; y: number } @@ -31,6 +31,7 @@ type DetectedRoom = { } const DEFAULT_AUTO_SLAB_ELEVATION = 0.05 +const DEFAULT_AUTO_CEILING_HEIGHT = 2.5 const ROOM_CURVE_TOLERANCE = 0.04 const MAX_CURVE_SUBDIVISION_DEPTH = 6 const AUTO_SLAB_POLYGON_SIMPLIFY_TOLERANCE = 0.08 @@ -417,14 +418,15 @@ export function resolveWallSurfaceSides( } function nextAutoRoomName( - slabs: Array<{ + nodes: Array<{ name?: string }>, + suffix: 'Slab' | 'Ceiling', ) { let maxIndex = 0 - for (const slab of slabs) { - const match = /^Room\s+(\d+)$/.exec((slab.name ?? '').trim()) + for (const node of nodes) { + const match = /^Room\s+(\d+)(?:\s+(?:Slab|Ceiling))?$/i.exec((node.name ?? '').trim()) if (!match) continue const index = Number(match[1]) 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) { @@ -567,14 +579,7 @@ function syncAutoSlabsForLevel( const polygon = updatesById.get(slab.id) if (!polygon) return [] - const samePolygon = - 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 } }] + return sameTuplePolygon(slab.polygon, polygon) ? [] : [{ id: slab.id, data: { polygon } }] }) const plannedSlabsForNaming: Array<{ name?: string }> = [...existingSlabs] @@ -585,7 +590,7 @@ function syncAutoSlabsForLevel( const room = detected[index] if (!room) continue - const name = nextAutoRoomName(plannedSlabsForNaming) + const name = nextAutoRoomName(plannedSlabsForNaming, 'Slab') plannedSlabsForNaming.push({ name }) 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() + const matchedDetectedIdx = new Set() + const updatesById = new Map() + + const autoBySignature = new Map() + 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[]) { const roomPolygons = extractRoomPolygons(walls) const wallUpdates: WallSideUpdate[] = walls.map((wall) => ({ @@ -657,6 +805,9 @@ function runSpaceDetection( const slabs = Object.values(nodes).filter( (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) @@ -683,6 +834,12 @@ function runSpaceDetection( slabs.map((slab: any) => SlabNode.parse(slab)), sceneStore, ) + syncAutoCeilingsForLevel( + levelId, + roomPolygons, + ceilings.map((ceiling: any) => CeilingNode.parse(ceiling)), + sceneStore, + ) for (const space of spaces) { nextSpaces[space.id] = space diff --git a/packages/core/src/schema/nodes/ceiling.ts b/packages/core/src/schema/nodes/ceiling.ts index 70e89e21..d4de71b6 100644 --- a/packages/core/src/schema/nodes/ceiling.ts +++ b/packages/core/src/schema/nodes/ceiling.ts @@ -13,11 +13,13 @@ export const CeilingNode = BaseNode.extend({ polygon: z.array(z.tuple([z.number(), z.number()])), holes: z.array(z.array(z.tuple([z.number(), z.number()]))).default([]), height: z.number().default(2.5), // Height in meters + autoFromWalls: z.boolean().default(false), }).describe( dedent` Ceiling node - used to represent a ceiling in the building - polygon: array of [x, z] points defining the ceiling boundary - holes: array of polygons representing holes in the ceiling + - autoFromWalls: whether the ceiling is automatically generated from a closed wall loop `, ) diff --git a/packages/editor/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx b/packages/editor/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx index 2cc12834..40c01c95 100644 --- a/packages/editor/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +++ b/packages/editor/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx @@ -2,30 +2,30 @@ import { type CeilingNode, + emitter, resolveLevelId, sceneRegistry, useScene, } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' 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 useEditor from '../../../store/use-editor' const BRACKET_THICKNESS = 0.04 const BRACKET_HEIGHT = 0.04 const BRACKET_Y_OFFSET = 0.035 -const CORNER_BLOCK_SIZE = 0.085 -const HIT_BOX_SIZE: [number, number, number] = [0.24, 0.12, 0.24] -const HIT_INSET = 0.16 +const HIT_BOX_SIZE: [number, number, number] = [0.28, 0.08, 0.28] type CornerBracketData = { corner: [number, number] - hitCenter: [number, number] incomingDirection: [number, number] outgoingDirection: [number, number] incomingLength: number outgoingLength: number + cornerStrength: number } export const CeilingSelectionAffordanceSystem = () => { @@ -75,13 +75,37 @@ const CeilingSelectionAffordance = ({ ceiling: CeilingNode levelId: string }) => { - const selectedIds = useViewer((state) => state.selection.selectedIds) - const isSelected = selectedIds.includes(ceiling.id) - const levelObject = sceneRegistry.nodes.get(levelId) + const [levelObject, setLevelObject] = useState(() => sceneRegistry.nodes.get(levelId) ?? null) 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( @@ -104,17 +128,16 @@ const CornerBracket = ({ ceiling: CeilingNode corner: CornerBracketData }) => { + const [isHovered, setIsHovered] = useState(false) const color = '#d4d4d4' const opacity = 0.72 + const cubeColor = isHovered ? '#818cf8' : '#d4d4d4' + const cubeOpacity = isHovered ? 0.92 : 0.72 - const handleClick = (e: ThreeEvent) => { - if (e.button !== 0) return + const handleClick = (e: ThreeEvent) => { e.stopPropagation() 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().setMovingWallEndpoint(null) @@ -122,39 +145,45 @@ const CornerBracket = ({ useEditor.getState().setEditingHole(null) useEditor.getState().setMode('select') - useViewer.getState().setSelection({ - buildingId: buildingId ?? selection.buildingId, - levelId, - selectedIds: [ceiling.id], + emitter.emit('ceiling:click' as any, { + node: ceiling, + nativeEvent: e.nativeEvent, + localPosition: [0, 0, 0], + position: [corner.corner[0], ceiling.height ?? 2.5, corner.corner[1]], + stopPropagation: () => e.stopPropagation(), }) } return ( - - - - - { + e.stopPropagation() + setIsHovered(true) + }} + onPointerLeave={(e) => { + e.stopPropagation() + setIsHovered(false) + }} > - + ) @@ -164,11 +193,13 @@ const BracketLeg = ({ direction, length, color, + onClick, opacity, }: { direction: [number, number] length: number color: string + onClick: (e: ThreeEvent) => void opacity: number }) => { const angle = Math.atan2(direction[1], direction[0]) @@ -179,7 +210,11 @@ const BracketLeg = ({ ] return ( - + @@ -189,31 +224,41 @@ const BracketLeg = ({ function buildCornerBrackets(polygon: Array<[number, number]>): CornerBracketData[] { 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 next = polygon[(index + 1) % polygon.length]! 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 incomingDirection = normalize2D(incomingVector) + const outgoingDirection = normalize2D(outgoingVector) const incomingLength = Math.hypot(incomingVector[0], incomingVector[1]) const outgoingLength = Math.hypot(outgoingVector[0], outgoingVector[1]) - const insetDirection = normalize2D([ - normalize2D(incomingVector)[0] + normalize2D(outgoingVector)[0], - normalize2D(incomingVector)[1] + normalize2D(outgoingVector)[1], - ]) + const cornerStrength = 1 - Math.abs(incomingDirection[0] * outgoingDirection[0] + incomingDirection[1] * outgoingDirection[1]) return { corner, - hitCenter: [ - corner[0] + insetDirection[0] * HIT_INSET, - corner[1] + insetDirection[1] * HIT_INSET, - ], - incomingDirection: normalize2D(incomingVector), - outgoingDirection: normalize2D(outgoingVector), + incomingDirection, + outgoingDirection, incomingLength: getBracketLength(incomingLength), 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] { @@ -225,9 +270,3 @@ function normalize2D(vector: [number, number]): [number, number] { function getBracketLength(edgeLength: number): number { return Math.max(0.14, Math.min(0.38, edgeLength * 0.22)) } - -function findBuildingId(levelId: string | null, nodes: Record): string | null { - if (!levelId) return null - const level = nodes[levelId] - return level?.parentId ?? null -} diff --git a/packages/editor/src/components/tools/shared/polygon-editor.tsx b/packages/editor/src/components/tools/shared/polygon-editor.tsx index 761d5370..3fe0c68c 100644 --- a/packages/editor/src/components/tools/shared/polygon-editor.tsx +++ b/packages/editor/src/components/tools/shared/polygon-editor.tsx @@ -1,7 +1,7 @@ import { emitter, type GridEvent, sceneRegistry } from '@pascal-app/core' import { createPortal } from '@react-three/fiber' 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 { sfxEmitter } from '../../../lib/sfx-bus' @@ -44,8 +44,40 @@ export const PolygonEditor: React.FC = ({ surfaceHeight = 0, allowPolygonMove = false, }) => { - // Get level node from registry if levelId is provided - const levelNode = levelId ? sceneRegistry.nodes.get(levelId) : null + const [levelNode, setLevelNode] = useState(() => + 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 not using portal, edit at world origin diff --git a/packages/editor/src/components/tools/wall/curve-wall-tool.tsx b/packages/editor/src/components/tools/wall/curve-wall-tool.tsx index a02ea2b0..374911bb 100644 --- a/packages/editor/src/components/tools/wall/curve-wall-tool.tsx +++ b/packages/editor/src/components/tools/wall/curve-wall-tool.tsx @@ -51,6 +51,9 @@ export const CurveWallTool: React.FC<{ node: WallNode }> = ({ node }) => { let wasCommitted = false const applyPreview = (curveOffset: number) => { + if (previewOffsetRef.current === curveOffset) { + return + } previewOffsetRef.current = curveOffset const nextNode = { @@ -64,6 +67,10 @@ export const CurveWallTool: React.FC<{ node: WallNode }> = ({ node }) => { } const restoreOriginal = () => { + if (previewOffsetRef.current === originalCurveOffset) { + return + } + previewOffsetRef.current = originalCurveOffset useScene.getState().updateNode(nodeId, { curveOffset: originalCurveOffset }) useScene.getState().markDirty(nodeId as AnyNodeId) } @@ -100,8 +107,10 @@ export const CurveWallTool: React.FC<{ node: WallNode }> = ({ node }) => { const curveOffset = previewOffsetRef.current wasCommitted = true useScene.temporal.getState().resume() - useScene.getState().updateNode(nodeId, { curveOffset }) - useScene.getState().markDirty(nodeId as AnyNodeId) + if (curveOffset !== getClampedWallCurveOffset(node)) { + useScene.getState().updateNode(nodeId, { curveOffset }) + useScene.getState().markDirty(nodeId as AnyNodeId) + } useScene.temporal.getState().pause() sfxEmitter.emit('sfx:item-place')