Merge pull request #296 from sudhir9297/wed-6-may-bug-fix

fix: Improve item surface placement, floorplan resizing, and placement preview stability
This commit is contained in:
Wassim SAMAD
2026-05-07 13:09:20 -04:00
committed by GitHub
15 changed files with 1682 additions and 280 deletions
@@ -1,5 +1,5 @@
import type { AnyNode, CeilingNode, ItemNode, SlabNode, WallNode } from '../../schema' import type { AnyNode, CeilingNode, ItemNode, SlabNode, WallNode } from '../../schema'
import { getScaledDimensions } from '../../schema' import { getScaledDimensions, isLowProfileItemSurface } from '../../schema'
import useScene from '../../store/use-scene' import useScene from '../../store/use-scene'
import { SpatialGrid } from './spatial-grid' import { SpatialGrid } from './spatial-grid'
import { WallSpatialGrid } from './wall-spatial-grid' import { WallSpatialGrid } from './wall-spatial-grid'
@@ -582,6 +582,7 @@ export class SpatialGridManager {
if (node.type !== 'item') continue if (node.type !== 'item') continue
const item = node as ItemNode const item = node as ItemNode
if (item.asset.attachTo) continue if (item.asset.attachTo) continue
if (isLowProfileItemSurface(item)) continue
if (ignoreSet.has(item.id)) continue if (ignoreSet.has(item.id)) continue
if (resolveNodeLevelId(item, nodes) !== levelId) continue if (resolveNodeLevelId(item, nodes) !== levelId) continue
+6 -1
View File
@@ -55,7 +55,12 @@ export type {
TemperatureControl, TemperatureControl,
ToggleControl, ToggleControl,
} from './nodes/item' } from './nodes/item'
export { getScaledDimensions, ItemNode } from './nodes/item' export {
getScaledDimensions,
ItemNode,
isLowProfileItemSurface,
LOW_PROFILE_ITEM_SURFACE_MAX_HEIGHT,
} from './nodes/item'
export { LevelNode } from './nodes/level' export { LevelNode } from './nodes/level'
export type { RoofSurfaceMaterialRole, RoofSurfaceMaterialSpec } from './nodes/roof' export type { RoofSurfaceMaterialRole, RoofSurfaceMaterialSpec } from './nodes/roof'
export { getEffectiveRoofSurfaceMaterial, RoofNode } from './nodes/roof' export { getEffectiveRoofSurfaceMaterial, RoofNode } from './nodes/roof'
+14
View File
@@ -135,6 +135,20 @@ export const ItemNode = BaseNode.extend({
export type ItemNode = z.infer<typeof ItemNode> export type ItemNode = z.infer<typeof ItemNode>
export const LOW_PROFILE_ITEM_SURFACE_MAX_HEIGHT = 0.1
/**
* Low, floor-resting items like rugs and parking mats can receive items visually,
* but should not become item parents or block normal floor placement.
*/
export function isLowProfileItemSurface(item: ItemNode): boolean {
if (item.asset.attachTo) return false
const surfaceHeight = item.asset.surface
? item.asset.surface.height * item.scale[1]
: getScaledDimensions(item)[1]
return surfaceHeight <= LOW_PROFILE_ITEM_SURFACE_MAX_HEIGHT
}
/** /**
* Returns the effective world-space dimensions of an item after applying its scale. * Returns the effective world-space dimensions of an item after applying its scale.
* Use this everywhere item.asset.dimensions is used for spatial calculations. * Use this everywhere item.asset.dimensions is used for spatial calculations.
@@ -26,6 +26,7 @@ type FloorplanActionMenuLayerProps = {
slab: FloorplanActionMenuEntry slab: FloorplanActionMenuEntry
ceiling: FloorplanActionMenuEntry ceiling: FloorplanActionMenuEntry
opening: FloorplanActionMenuEntry opening: FloorplanActionMenuEntry
spawn: FloorplanActionMenuEntry
stair: FloorplanActionMenuEntry stair: FloorplanActionMenuEntry
roof: FloorplanActionMenuEntry roof: FloorplanActionMenuEntry
offsetY?: number offsetY?: number
@@ -38,6 +39,7 @@ export const FloorplanActionMenuLayer = memo(function FloorplanActionMenuLayer({
slab, slab,
ceiling, ceiling,
opening, opening,
spawn,
stair, stair,
roof, roof,
offsetY = 10, offsetY = 10,
@@ -59,6 +61,7 @@ export const FloorplanActionMenuLayer = memo(function FloorplanActionMenuLayer({
slab, slab,
ceiling, ceiling,
opening, opening,
spawn,
stair, stair,
roof, roof,
] ]
@@ -3,11 +3,11 @@
import type { Point2D } from '@pascal-app/core' import type { Point2D } from '@pascal-app/core'
function toSvgX(value: number) { function toSvgX(value: number) {
return -value return value
} }
function toSvgY(value: number) { function toSvgY(value: number) {
return -value return value
} }
function toSvgPoint(point: Point2D) { function toSvgPoint(point: Point2D) {
@@ -100,9 +100,7 @@ export function buildSvgAnnularSectorPath(
} }
export function formatSvgPolygonPoints(points: Point2D[]) { export function formatSvgPolygonPoints(points: Point2D[]) {
return points return points.map((point) => `${toSvgX(point.x)},${toSvgY(point.y)}`).join(' ')
.map((point) => `${toSvgX(point.x)},${toSvgY(point.y)}`)
.join(' ')
} }
export function buildSvgArrowHeadPoints(point: Point2D, angle: number, size: number) { export function buildSvgArrowHeadPoints(point: Point2D, angle: number, size: number) {
File diff suppressed because it is too large Load Diff
@@ -11,6 +11,7 @@ import {
type RoofNode, type RoofNode,
type SiteNode, type SiteNode,
type SlabNode, type SlabNode,
type SpawnNode,
useScene, useScene,
type WallNode, type WallNode,
type WindowNode, type WindowNode,
@@ -113,6 +114,7 @@ export function useFloorplanSceneData({
) )
const levelGuides = useLevelChildren(levelId, (node): node is GuideNode => node?.type === 'guide') const levelGuides = useLevelChildren(levelId, (node): node is GuideNode => node?.type === 'guide')
const zones = useLevelChildren(levelId, (node): node is ZoneNodeType => node?.type === 'zone') const zones = useLevelChildren(levelId, (node): node is ZoneNodeType => node?.type === 'zone')
const spawns = useLevelChildren(levelId, (node): node is SpawnNode => node?.type === 'spawn')
const roofs = useScene( const roofs = useScene(
useShallow((state) => { useShallow((state) => {
if (!levelId) { if (!levelId) {
@@ -180,6 +182,7 @@ export function useFloorplanSceneData({
roofs, roofs,
site, site,
slabs, slabs,
spawns,
walls, walls,
zones, zones,
} }
@@ -4,7 +4,6 @@ import {
type AnyNodeId, type AnyNodeId,
calculateLevelMiters, calculateLevelMiters,
DEFAULT_WALL_HEIGHT, DEFAULT_WALL_HEIGHT,
getScaledDimensions,
getWallCurveLength, getWallCurveLength,
getWallMiterBoundaryPoints, getWallMiterBoundaryPoints,
getWallPlanFootprint, getWallPlanFootprint,
@@ -428,30 +427,6 @@ function buildMeasurementGuide(
} }
} }
type HeightGuide = {
start: Vec3
end: Vec3
labelPosition: Vec3
}
function buildItemHeightGuide(item: ItemNode): { guide: HeightGuide; height: number } | null {
const [width, height, depth] = getScaledDimensions(item)
if (!Number.isFinite(height) || height < 0.01) return null
const x = Number.isFinite(width) ? width / 2 + 0.18 : 0.18
const z = Number.isFinite(depth) ? depth / 2 + 0.18 : 0.18
return {
height,
guide: {
start: [x, 0, z],
end: [x, height, z],
labelPosition: [x, height / 2, z],
},
}
}
function MeasurementBar({ start, end, color }: { start: Vec3; end: Vec3; color: string }) { function MeasurementBar({ start, end, color }: { start: Vec3; end: Vec3; color: string }) {
const segment = useMemo(() => { const segment = useMemo(() => {
const startVector = new THREE.Vector3(...start) const startVector = new THREE.Vector3(...start)
@@ -535,7 +510,7 @@ function SelectedMeasurementAnnotation({ node }: { node: WallNode | ItemNode })
return <WallMeasurementAnnotation wall={node} /> return <WallMeasurementAnnotation wall={node} />
} }
return <ItemHeightMeasurementAnnotation item={node} /> return null
} }
function WallMeasurementAnnotation({ wall }: { wall: WallNode }) { function WallMeasurementAnnotation({ wall }: { wall: WallNode }) {
@@ -600,27 +575,3 @@ function WallMeasurementAnnotation({ wall }: { wall: WallNode }) {
</group> </group>
) )
} }
function ItemHeightMeasurementAnnotation({ item }: { item: ItemNode }) {
const theme = useViewer((state) => state.theme)
const unit = useViewer((state) => state.unit)
const isNight = theme === 'dark'
const color = isNight ? '#ffffff' : '#111111'
const shadowColor = isNight ? '#111111' : '#ffffff'
const measurement = useMemo(() => buildItemHeightGuide(item), [item])
if (!measurement) return null
return (
<group>
<MeasurementBar color={color} end={measurement.guide.end} start={measurement.guide.start} />
<MeasurementLabel
color={color}
label={`H ${formatMeasurement(measurement.height, unit)}`}
position={measurement.guide.labelPosition}
shadowColor={shadowColor}
/>
</group>
)
}
@@ -31,6 +31,7 @@ export const CeilingBoundaryEditor: React.FC<CeilingBoundaryEditorProps> = ({ ce
return ( return (
<PolygonEditor <PolygonEditor
allowEdgeMove
color="#d4d4d4" color="#d4d4d4"
levelId={resolveLevelId(ceiling, useScene.getState().nodes)} levelId={resolveLevelId(ceiling, useScene.getState().nodes)}
minVertices={3} minVertices={3}
@@ -36,6 +36,7 @@ export const CeilingHoleEditor: React.FC<CeilingHoleEditorProps> = ({ ceilingId,
return ( return (
<PolygonEditor <PolygonEditor
allowEdgeMove
allowPolygonMove allowPolygonMove
color="#ef4444" color="#ef4444"
levelId={resolveLevelId(ceiling, useScene.getState().nodes)} // red for holes levelId={resolveLevelId(ceiling, useScene.getState().nodes)} // red for holes
@@ -9,8 +9,13 @@ import type {
WallEvent, WallEvent,
WallNode, WallNode,
} from '@pascal-app/core' } from '@pascal-app/core'
import { getScaledDimensions, sceneRegistry, useScene } from '@pascal-app/core' import {
import { Euler, Quaternion, Vector3 } from 'three' getScaledDimensions,
isLowProfileItemSurface,
sceneRegistry,
useScene,
} from '@pascal-app/core'
import { Euler, Matrix3, Quaternion, Vector3 } from 'three'
import { import {
calculateCursorRotation, calculateCursorRotation,
calculateItemRotation, calculateItemRotation,
@@ -31,6 +36,46 @@ import type {
} from './placement-types' } from './placement-types'
const DEFAULT_DIMENSIONS: [number, number, number] = [1, 1, 1] const DEFAULT_DIMENSIONS: [number, number, number] = [1, 1, 1]
const UPWARD_SURFACE_NORMAL_MIN_Y = 0.75
function getWorldNormalY(event: ItemEvent): number | null {
if (!event.normal) return null
const normal = new Vector3(event.normal[0], event.normal[1], event.normal[2])
normal.applyNormalMatrix(new Matrix3().getNormalMatrix(event.object.matrixWorld)).normalize()
return normal.y
}
function isUpwardItemSurfaceHit(event: ItemEvent): boolean {
const normalY = getWorldNormalY(event)
return normalY !== null && normalY >= UPWARD_SURFACE_NORMAL_MIN_Y
}
function getSurfacePlacementHeight(surfaceItem: ItemNode, event: ItemEvent, localPos: Vector3) {
if (isLowProfileItemSurface(surfaceItem)) return null
if (!isUpwardItemSurfaceHit(event)) return null
if (surfaceItem.asset.surface) {
return surfaceItem.asset.surface.height * surfaceItem.scale[1]
}
if (!Number.isFinite(localPos.y)) return null
return localPos.y
}
function isDescendantOfItem(
candidate: ItemNode,
ancestor: ItemNode,
nodes: Record<string, AnyNode>,
): boolean {
let parentId = candidate.parentId
while (parentId) {
if (parentId === ancestor.id) return true
const parent = nodes[parentId as AnyNodeId]
parentId = parent?.parentId ?? null
}
return false
}
// ============================================================================ // ============================================================================
// FLOOR STRATEGY // FLOOR STRATEGY
@@ -434,8 +479,11 @@ export const itemSurfaceStrategy = {
const surfaceItem = event.node as ItemNode const surfaceItem = event.node as ItemNode
// Don't surface-place on the draft itself // Don't surface-place on the draft itself
if (surfaceItem.id === ctx.draftItem?.id) return null if (surfaceItem.id === ctx.draftItem?.id) return null
// Surface item must declare a surface if (ctx.state.surface === 'item-surface' && ctx.state.surfaceItemId === surfaceItem.id) {
if (!surfaceItem.asset.surface) return null return null
}
const nodes = useScene.getState().nodes
if (ctx.draftItem && isDescendantOfItem(surfaceItem, ctx.draftItem, nodes)) return null
// Size check: our footprint must fit on surface item's footprint // Size check: our footprint must fit on surface item's footprint
const ourDims = ctx.draftItem const ourDims = ctx.draftItem
@@ -449,10 +497,12 @@ export const itemSurfaceStrategy = {
const worldPos = new Vector3(event.position[0], event.position[1], event.position[2]) const worldPos = new Vector3(event.position[0], event.position[1], event.position[2])
const localPos = surfaceMesh.worldToLocal(worldPos) const localPos = surfaceMesh.worldToLocal(worldPos)
const surfaceHeight = getSurfacePlacementHeight(surfaceItem, event, localPos)
if (surfaceHeight === null) return null
const x = snapToGrid(localPos.x, ourDims[0]) const x = snapToGrid(localPos.x, ourDims[0])
const z = snapToGrid(localPos.z, ourDims[2]) const z = snapToGrid(localPos.z, ourDims[2])
const y = surfaceItem.asset.surface.height * surfaceItem.scale[1] const y = surfaceHeight
const worldSnapped = surfaceMesh.localToWorld(new Vector3(x, y, z)) const worldSnapped = surfaceMesh.localToWorld(new Vector3(x, y, z))
@@ -485,10 +535,11 @@ export const itemSurfaceStrategy = {
move(ctx: PlacementContext, event: ItemEvent): PlacementResult | null { move(ctx: PlacementContext, event: ItemEvent): PlacementResult | null {
if (ctx.state.surface !== 'item-surface') return null if (ctx.state.surface !== 'item-surface') return null
if (!(ctx.state.surfaceItemId && ctx.draftItem)) return null if (!(ctx.state.surfaceItemId && ctx.draftItem)) return null
if (event.node.id !== ctx.state.surfaceItemId) return null
const nodes = useScene.getState().nodes const nodes = useScene.getState().nodes
const surfaceItem = nodes[ctx.state.surfaceItemId as AnyNodeId] as ItemNode | undefined const surfaceItem = nodes[ctx.state.surfaceItemId as AnyNodeId] as ItemNode | undefined
if (!surfaceItem?.asset.surface) return null if (!surfaceItem) return null
const surfaceMesh = sceneRegistry.nodes.get(ctx.state.surfaceItemId) const surfaceMesh = sceneRegistry.nodes.get(ctx.state.surfaceItemId)
if (!surfaceMesh) return null if (!surfaceMesh) return null
@@ -496,10 +547,12 @@ export const itemSurfaceStrategy = {
const ourDims = getScaledDimensions(ctx.draftItem) const ourDims = getScaledDimensions(ctx.draftItem)
const worldPos = new Vector3(event.position[0], event.position[1], event.position[2]) const worldPos = new Vector3(event.position[0], event.position[1], event.position[2])
const localPos = surfaceMesh.worldToLocal(worldPos) const localPos = surfaceMesh.worldToLocal(worldPos)
const surfaceHeight = getSurfacePlacementHeight(surfaceItem, event, localPos)
if (surfaceHeight === null) return null
const x = snapToGrid(localPos.x, ourDims[0]) const x = snapToGrid(localPos.x, ourDims[0])
const z = snapToGrid(localPos.z, ourDims[2]) const z = snapToGrid(localPos.z, ourDims[2])
const y = surfaceItem.asset.surface.height * surfaceItem.scale[1] const y = surfaceHeight
const worldSnapped = surfaceMesh.localToWorld(new Vector3(x, y, z)) const worldSnapped = surfaceMesh.localToWorld(new Vector3(x, y, z))
@@ -519,6 +572,7 @@ export const itemSurfaceStrategy = {
click(ctx: PlacementContext, _event: ItemEvent): CommitResult | null { click(ctx: PlacementContext, _event: ItemEvent): CommitResult | null {
if (ctx.state.surface !== 'item-surface') return null if (ctx.state.surface !== 'item-surface') return null
if (!(ctx.draftItem && ctx.state.surfaceItemId)) return null if (!(ctx.draftItem && ctx.state.surfaceItemId)) return null
if (_event.node.id !== ctx.state.surfaceItemId) return null
return { return {
nodeUpdate: { nodeUpdate: {
@@ -17,13 +17,11 @@ import {
} from '@pascal-app/core' } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { Html } from '@react-three/drei' import { Html } from '@react-three/drei'
import { createPortal, useFrame } from '@react-three/fiber' import { useFrame } from '@react-three/fiber'
import { useEffect, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { import {
BoxGeometry,
Box3, Box3,
BufferGeometry, BufferGeometry,
EdgesGeometry,
Euler, Euler,
Float32BufferAttribute, Float32BufferAttribute,
type Group, type Group,
@@ -199,21 +197,121 @@ function getFallbackPreviewBounds(
): PreviewBounds { ): PreviewBounds {
const dims = item ? getScaledDimensions(item) : (asset.dimensions ?? DEFAULT_DIMENSIONS) const dims = item ? getScaledDimensions(item) : (asset.dimensions ?? DEFAULT_DIMENSIONS)
return { return {
min: [ min: [-dims[0] / 2, 0, attachTo === 'wall-side' ? -dims[2] : -dims[2] / 2],
-dims[0] / 2, max: [dims[0] / 2, dims[1], attachTo === 'wall-side' ? 0 : dims[2] / 2],
0,
attachTo === 'wall-side' ? -dims[2] : -dims[2] / 2,
],
max: [
dims[0] / 2,
dims[1],
attachTo === 'wall-side' ? 0 : dims[2] / 2,
],
dimensions: dims, dimensions: dims,
center: [0, dims[1] / 2, attachTo === 'wall-side' ? -dims[2] / 2 : 0], center: [0, dims[1] / 2, attachTo === 'wall-side' ? -dims[2] / 2 : 0],
} }
} }
function createLineGeometry(points: number[] = [0, 0, 0, 0, 0, 0]): BufferGeometry {
const geometry = new BufferGeometry()
geometry.setAttribute('position', new Float32BufferAttribute(points, 3))
return geometry
}
function getBoxEdgePoints(bounds: PreviewBounds): number[] {
const [width, height, depth] = bounds.dimensions
const [centerX, centerY, centerZ] = bounds.center
const minX = centerX - width / 2
const maxX = centerX + width / 2
const minY = centerY - height / 2
const maxY = centerY + height / 2
const minZ = centerZ - depth / 2
const maxZ = centerZ + depth / 2
return [
minX,
minY,
minZ,
maxX,
minY,
minZ,
maxX,
minY,
minZ,
maxX,
minY,
maxZ,
maxX,
minY,
maxZ,
minX,
minY,
maxZ,
minX,
minY,
maxZ,
minX,
minY,
minZ,
minX,
maxY,
minZ,
maxX,
maxY,
minZ,
maxX,
maxY,
minZ,
maxX,
maxY,
maxZ,
maxX,
maxY,
maxZ,
minX,
maxY,
maxZ,
minX,
maxY,
maxZ,
minX,
maxY,
minZ,
minX,
minY,
minZ,
minX,
maxY,
minZ,
maxX,
minY,
minZ,
maxX,
maxY,
minZ,
maxX,
minY,
maxZ,
maxX,
maxY,
maxZ,
minX,
minY,
maxZ,
minX,
maxY,
maxZ,
]
}
function updateLineGeometry(ref: React.RefObject<LineSegments>, points: number[]) {
const geometry = ref.current?.geometry
if (!geometry) return
const attribute = geometry.getAttribute('position') as Float32BufferAttribute | undefined
if (!attribute || attribute.array.length !== points.length) {
geometry.setAttribute('position', new Float32BufferAttribute(points, 3))
} else {
attribute.set(points)
attribute.needsUpdate = true
}
geometry.computeBoundingSphere()
}
// Shared materials for placement cursor - we just change colors, not swap materials // Shared materials for placement cursor - we just change colors, not swap materials
// Note: EdgesGeometry doesn't work with dashed lines, so using solid lines // Note: EdgesGeometry doesn't work with dashed lines, so using solid lines
const edgeMaterial = new LineBasicNodeMaterial({ const edgeMaterial = new LineBasicNodeMaterial({
@@ -269,11 +367,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
const shiftFreeRef = useRef(false) const shiftFreeRef = useRef(false)
const previewBoundsSignatureRef = useRef<string | null>(null) const previewBoundsSignatureRef = useRef<string | null>(null)
const meshPreviewAppliedRef = useRef(false) const meshPreviewAppliedRef = useRef(false)
const dimensionBoundsRef = useRef<PreviewBounds | null>(null) const [dimensionBounds, setDimensionBounds] = useState<PreviewBounds | null>(null)
const [measurementTargetState, setMeasurementTargetState] = useState<{
id: string
object: Object3D
} | null>(null)
// Store config callbacks in refs to avoid re-running effect when they change // Store config callbacks in refs to avoid re-running effect when they change
const configRef = useRef(config) const configRef = useRef(config)
@@ -291,23 +385,33 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
if (previewBoundsSignatureRef.current === signature) return if (previewBoundsSignatureRef.current === signature) return
previewBoundsSignatureRef.current = signature previewBoundsSignatureRef.current = signature
const nextBoxGeometry = new BoxGeometry(width, height, depth)
nextBoxGeometry.translate(centerX, centerY, centerZ)
const nextEdgesGeometry = new EdgesGeometry(nextBoxGeometry)
const nextBasePlaneGeometry = new PlaneGeometry(width, depth) const nextBasePlaneGeometry = new PlaneGeometry(width, depth)
nextBasePlaneGeometry.rotateX(-Math.PI / 2) nextBasePlaneGeometry.rotateX(-Math.PI / 2)
nextBasePlaneGeometry.translate(centerX, 0.01, centerZ) nextBasePlaneGeometry.translate(centerX, 0.01, centerZ)
edgesRef.current.geometry.dispose() updateLineGeometry(edgesRef, getBoxEdgePoints(bounds))
edgesRef.current.geometry = nextEdgesGeometry
basePlaneRef.current.geometry.dispose() const oldBasePlaneGeometry = basePlaneRef.current.geometry
basePlaneRef.current.geometry = nextBasePlaneGeometry basePlaneRef.current.geometry = nextBasePlaneGeometry
nextBoxGeometry.dispose() oldBasePlaneGeometry.dispose()
} }
const updateDimensionGuides = (bounds: PreviewBounds) => { const updateDimensionGuides = (bounds: PreviewBounds) => {
dimensionBoundsRef.current = bounds setDimensionBounds((current) => {
if (
current &&
current.dimensions[0] === bounds.dimensions[0] &&
current.dimensions[1] === bounds.dimensions[1] &&
current.dimensions[2] === bounds.dimensions[2] &&
current.center[0] === bounds.center[0] &&
current.center[1] === bounds.center[1] &&
current.center[2] === bounds.center[2]
) {
return current
}
return bounds
})
const [width, , depth] = bounds.dimensions const [width, , depth] = bounds.dimensions
const [centerX, , centerZ] = bounds.center const [centerX, , centerZ] = bounds.center
const minX = centerX - width / 2 const minX = centerX - width / 2
@@ -388,10 +492,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
] ]
const applyPoints = (ref: React.RefObject<LineSegments>, points: number[]) => { const applyPoints = (ref: React.RefObject<LineSegments>, points: number[]) => {
const geometry = new BufferGeometry() updateLineGeometry(ref, points)
geometry.setAttribute('position', new Float32BufferAttribute(points, 3))
ref.current!.geometry.dispose()
ref.current!.geometry = geometry
} }
applyPoints(measurementWidthRef, widthPoints) applyPoints(measurementWidthRef, widthPoints)
@@ -413,6 +514,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
ceilingId: null, ceilingId: null,
surfaceItemId: null, surfaceItemId: null,
} }
if (!asset.attachTo && placementState.current.surface === 'floor') {
gridPosition.current.y = 0
cursorGroupRef.current.position.y = 0
}
// ---- Helpers ---- // ---- Helpers ----
@@ -540,9 +645,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
previousGridPos = [...result.gridPosition] previousGridPos = [...result.gridPosition]
gridPosition.current.set(...result.gridPosition) gridPosition.current.set(...result.gridPosition)
// Only update X and Z for cursor - useFrame will handle Y (slab elevation) cursorGroupRef.current.position.set(
cursorGroupRef.current.position.x = result.cursorPosition[0] result.cursorPosition[0],
cursorGroupRef.current.position.z = result.cursorPosition[2] result.cursorPosition[1],
result.cursorPosition[2],
)
const draft = draftNode.current const draft = draftNode.current
if (draft) draft.position = result.gridPosition if (draft) draft.position = result.gridPosition
@@ -767,6 +874,32 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
// ---- Item Surface Handlers ---- // ---- Item Surface Handlers ----
const detachItemSurfaceToFloor = (event: ItemEvent) => {
const buildingLocalPoint = worldToBuildingLocal(
event.position[0],
event.position[1],
event.position[2],
)
const wx = Math.round(buildingLocalPoint.x * 2) / 2
const wz = Math.round(buildingLocalPoint.z * 2) / 2
const floorPos: [number, number, number] = [wx, 0, wz]
Object.assign(placementState.current, { surface: 'floor', surfaceItemId: null })
gridPosition.current.set(wx, 0, wz)
cursorGroupRef.current.position.set(wx, 0, wz)
const draft = draftNode.current
if (draft) {
draft.position = floorPos
useScene.getState().updateNode(draft.id, {
parentId: useViewer.getState().selection.levelId as string,
position: floorPos,
})
}
revalidate()
}
const onItemEnter = (event: ItemEvent) => { const onItemEnter = (event: ItemEvent) => {
if (event.node.id === draftNode.current?.id) return if (event.node.id === draftNode.current?.id) return
const result = itemSurfaceStrategy.enter(getContext(), event) const result = itemSurfaceStrategy.enter(getContext(), event)
@@ -800,6 +933,24 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
return return
} }
if (ctx.state.surface === 'item-surface' && event.node.id !== ctx.state.surfaceItemId) {
const enterResult = itemSurfaceStrategy.enter(
{ ...ctx, state: { ...ctx.state, surface: 'floor', surfaceItemId: null } },
event,
)
event.stopPropagation()
if (enterResult) {
applyTransition(enterResult)
if (draftNode.current && enterResult.nodeUpdate.parentId) {
useScene.getState().updateNode(draftNode.current.id, enterResult.nodeUpdate)
}
} else {
detachItemSurfaceToFloor(event)
}
return
}
if (!draftNode.current) { if (!draftNode.current) {
const enterResult = itemSurfaceStrategy.enter(getContext(), event) const enterResult = itemSurfaceStrategy.enter(getContext(), event)
if (!enterResult) return if (!enterResult) return
@@ -846,30 +997,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
// building-local. Convert from world via worldToBuildingLocal instead, // building-local. Convert from world via worldToBuildingLocal instead,
// otherwise the wireframe jumps to a surface-local-coordinate ghost // otherwise the wireframe jumps to a surface-local-coordinate ghost
// position until the next mouse move. // position until the next mouse move.
const buildingLocalLeave = worldToBuildingLocal( detachItemSurfaceToFloor(event)
event.position[0],
event.position[1],
event.position[2],
)
const wx = Math.round(buildingLocalLeave.x * 2) / 2
const wz = Math.round(buildingLocalLeave.z * 2) / 2
const floorPos: [number, number, number] = [wx, 0, wz]
Object.assign(placementState.current, { surface: 'floor', surfaceItemId: null })
gridPosition.current.set(wx, 0, wz)
cursorGroupRef.current.position.x = wx
cursorGroupRef.current.position.z = wz
const draft = draftNode.current
if (draft) {
draft.position = floorPos
useScene.getState().updateNode(draft.id, {
parentId: useViewer.getState().selection.levelId as string,
position: floorPos,
})
}
revalidate()
} }
const onItemClick = (event: ItemEvent) => { const onItemClick = (event: ItemEvent) => {
@@ -927,11 +1055,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
return return
} }
lastRawPos.current.set( lastRawPos.current.set(event.localPosition[0], event.localPosition[1], event.localPosition[2])
event.localPosition[0],
event.localPosition[1],
event.localPosition[2],
)
const result = ceilingStrategy.move(getContext(), event) const result = ceilingStrategy.move(getContext(), event)
if (!result) return if (!result) return
@@ -1147,16 +1271,16 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
asset.attachTo, asset.attachTo,
gridSnapStep, gridSnapStep,
) )
updatePreviewGeometry( const previewBounds = draft
draft ? expandBoundsToGrid(
? (expandBoundsToGrid( getPreviewBoundsFromObject(sceneRegistry.nodes.get(draft.id) ?? null) ??
getPreviewBoundsFromObject(sceneRegistry.nodes.get(draft.id) ?? null) ?? getFallbackPreviewBounds(draft, asset, asset.attachTo), getFallbackPreviewBounds(draft, asset, asset.attachTo),
asset.attachTo, asset.attachTo,
gridSnapStep, gridSnapStep,
)) )
: fallbackBounds, : fallbackBounds
) updatePreviewGeometry(previewBounds)
updateDimensionGuides(fallbackBounds) updateDimensionGuides(previewBounds)
// ---- Undo protection ---- // ---- Undo protection ----
// Undo replaces the entire `nodes` object with a previous snapshot, which doesn't // Undo replaces the entire `nodes` object with a previous snapshot, which doesn't
@@ -1242,10 +1366,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
const meshBounds = draft const meshBounds = draft
? getPreviewBoundsFromObject(sceneRegistry.nodes.get(draft.id) ?? null) ? getPreviewBoundsFromObject(sceneRegistry.nodes.get(draft.id) ?? null)
: null : null
updatePreviewGeometry( const previewBounds = meshBounds
meshBounds ? expandBoundsToGrid(meshBounds, asset.attachTo, gridSnapStep) : fallbackBounds, ? expandBoundsToGrid(meshBounds, asset.attachTo, gridSnapStep)
) : fallbackBounds
updateDimensionGuides(fallbackBounds) updatePreviewGeometry(previewBounds)
updateDimensionGuides(previewBounds)
}, [gridSnapStep, asset, draftNode]) }, [gridSnapStep, asset, draftNode])
// Wall/ceiling items are managed by their own surface entry events (ensureDraft / reparent). // Wall/ceiling items are managed by their own surface entry events (ensureDraft / reparent).
const viewerLevelId = useViewer((s) => s.selection.levelId) const viewerLevelId = useViewer((s) => s.selection.levelId)
@@ -1263,19 +1388,16 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
if (!draftNode.current) return if (!draftNode.current) return
const mesh = sceneRegistry.nodes.get(draftNode.current.id) const mesh = sceneRegistry.nodes.get(draftNode.current.id)
if (!mesh) return if (!mesh) return
if (
measurementTargetState?.id !== draftNode.current.id ||
measurementTargetState.object !== mesh
) {
setMeasurementTargetState({ id: draftNode.current.id, object: mesh })
}
if (!meshPreviewAppliedRef.current) { if (!meshPreviewAppliedRef.current) {
const previewBounds = getPreviewBoundsFromObject(mesh) const previewBounds = getPreviewBoundsFromObject(mesh)
if (previewBounds) { if (previewBounds) {
updatePreviewGeometry( const expandedBounds = expandBoundsToGrid(
expandBoundsToGrid(previewBounds, asset.attachTo, useEditor.getState().gridSnapStep), previewBounds,
) asset.attachTo,
useEditor.getState().gridSnapStep,
)
updatePreviewGeometry(expandedBounds)
updateDimensionGuides(expandedBounds)
meshPreviewAppliedRef.current = true meshPreviewAppliedRef.current = true
} }
} }
@@ -1319,68 +1441,75 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
? getScaledDimensions(initialDraft) ? getScaledDimensions(initialDraft)
: (config.asset?.dimensions ?? DEFAULT_DIMENSIONS) : (config.asset?.dimensions ?? DEFAULT_DIMENSIONS)
const dims = getGridAlignedDimensions(rawDims, initialAttachTo, gridSnapStep) const dims = getGridAlignedDimensions(rawDims, initialAttachTo, gridSnapStep)
const initialBoxGeometry = new BoxGeometry(dims[0], dims[1], dims[2])
const wallSideZOffset = initialAttachTo === 'wall-side' ? -dims[2] / 2 : 0 const wallSideZOffset = initialAttachTo === 'wall-side' ? -dims[2] / 2 : 0
initialBoxGeometry.translate(0, dims[1] / 2, wallSideZOffset)
// Base plane geometry (colored rectangle on the ground)
const basePlaneGeometry = new PlaneGeometry(dims[0], dims[2])
basePlaneGeometry.rotateX(-Math.PI / 2) // Make it horizontal
basePlaneGeometry.translate(0, 0.01, wallSideZOffset) // Slightly above ground to avoid z-fighting
const initialDimensionBounds = expandBoundsToGrid( const initialDimensionBounds = expandBoundsToGrid(
getFallbackPreviewBounds(initialDraft, config.asset!, initialAttachTo), getFallbackPreviewBounds(initialDraft, config.asset!, initialAttachTo),
initialAttachTo, initialAttachTo,
gridSnapStep, gridSnapStep,
) )
const widthLabel = formatMeasurement(initialDimensionBounds.dimensions[0], unit) const initialEdgeGeometry = useMemo(
const depthLabel = formatMeasurement(initialDimensionBounds.dimensions[2], unit) () => createLineGeometry(getBoxEdgePoints(initialDimensionBounds)),
const heightLabel = formatMeasurement(initialDimensionBounds.dimensions[1], unit) [
initialDimensionBounds.center[0],
initialDimensionBounds.center[1],
initialDimensionBounds.center[2],
initialDimensionBounds.dimensions[0],
initialDimensionBounds.dimensions[1],
initialDimensionBounds.dimensions[2],
],
)
const basePlaneGeometry = useMemo(() => {
const geometry = new PlaneGeometry(dims[0], dims[2])
geometry.rotateX(-Math.PI / 2)
geometry.translate(0, 0.01, wallSideZOffset)
return geometry
}, [dims[0], dims[2], wallSideZOffset])
const initialWidthGuideGeometry = useMemo(() => createLineGeometry(), [])
const initialDepthGuideGeometry = useMemo(() => createLineGeometry(), [])
const initialHeightGuideGeometry = useMemo(() => createLineGeometry(), [])
const currentDimensionBounds = dimensionBounds ?? initialDimensionBounds
const widthLabel = formatMeasurement(currentDimensionBounds.dimensions[0], unit)
const depthLabel = formatMeasurement(currentDimensionBounds.dimensions[2], unit)
const heightLabel = formatMeasurement(currentDimensionBounds.dimensions[1], unit)
const widthLabelPosition: [number, number, number] = [ const widthLabelPosition: [number, number, number] = [
initialDimensionBounds.center[0], currentDimensionBounds.center[0],
0.04, 0.04,
initialDimensionBounds.center[2] + initialDimensionBounds.dimensions[2] / 2 + 0.24, currentDimensionBounds.center[2] + currentDimensionBounds.dimensions[2] / 2 + 0.24,
] ]
const depthLabelPosition: [number, number, number] = [ const depthLabelPosition: [number, number, number] = [
initialDimensionBounds.center[0] + initialDimensionBounds.dimensions[0] / 2 + 0.24, currentDimensionBounds.center[0] + currentDimensionBounds.dimensions[0] / 2 + 0.24,
0.04, 0.04,
initialDimensionBounds.center[2], currentDimensionBounds.center[2],
] ]
const heightLabelPosition: [number, number, number] = [ const heightLabelPosition: [number, number, number] = [
initialDimensionBounds.center[0] - initialDimensionBounds.dimensions[0] / 2 - 0.24, currentDimensionBounds.center[0] - currentDimensionBounds.dimensions[0] / 2 - 0.24,
initialDimensionBounds.dimensions[1] / 2, currentDimensionBounds.dimensions[1] / 2,
initialDimensionBounds.center[2] - initialDimensionBounds.dimensions[2] / 2, currentDimensionBounds.center[2] - currentDimensionBounds.dimensions[2] / 2,
] ]
const measurementTarget =
draftNode.current && measurementTargetState?.id === draftNode.current.id
? measurementTargetState.object
: null
const measurementContent = ( const measurementContent = (
<> <>
<lineSegments <lineSegments
layers={EDITOR_LAYER} layers={EDITOR_LAYER}
geometry={initialWidthGuideGeometry}
material={measurementMaterial} material={measurementMaterial}
ref={measurementWidthRef} ref={measurementWidthRef}
renderOrder={998} renderOrder={998}
> />
<bufferGeometry />
</lineSegments>
<lineSegments <lineSegments
layers={EDITOR_LAYER} layers={EDITOR_LAYER}
geometry={initialDepthGuideGeometry}
material={measurementMaterial} material={measurementMaterial}
ref={measurementDepthRef} ref={measurementDepthRef}
renderOrder={998} renderOrder={998}
> />
<bufferGeometry />
</lineSegments>
<lineSegments <lineSegments
layers={EDITOR_LAYER} layers={EDITOR_LAYER}
geometry={initialHeightGuideGeometry}
material={measurementMaterial} material={measurementMaterial}
ref={measurementHeightRef} ref={measurementHeightRef}
renderOrder={998} renderOrder={998}
> />
<bufferGeometry />
</lineSegments>
<Html center position={widthLabelPosition} style={{ pointerEvents: 'none' }}> <Html center position={widthLabelPosition} style={{ pointerEvents: 'none' }}>
<div <div
style={{ style={{
@@ -1443,10 +1572,14 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
return ( return (
<group ref={cursorGroupRef}> <group ref={cursorGroupRef}>
<lineSegments layers={EDITOR_LAYER} material={edgeMaterial} ref={edgesRef} renderOrder={999}> <lineSegments
<edgesGeometry args={[initialBoxGeometry]} /> geometry={initialEdgeGeometry}
</lineSegments> layers={EDITOR_LAYER}
{measurementTarget ? createPortal(measurementContent, measurementTarget) : measurementContent} material={edgeMaterial}
ref={edgesRef}
renderOrder={999}
/>
{measurementContent}
<mesh <mesh
geometry={basePlaneGeometry} geometry={basePlaneGeometry}
layers={EDITOR_LAYER} layers={EDITOR_LAYER}
@@ -10,8 +10,10 @@ const Y_OFFSET = 0.02
type DragState = { type DragState = {
isDragging: boolean isDragging: boolean
mode: 'vertex' | 'polygon' mode: 'vertex' | 'polygon' | 'edge'
vertexIndex: number | null vertexIndex: number | null
edgeIndex?: number
edgeNormal?: [number, number]
initialPosition: [number, number] initialPosition: [number, number]
initialPolygon: Array<[number, number]> initialPolygon: Array<[number, number]>
pointerId: number pointerId: number
@@ -28,6 +30,8 @@ export interface PolygonEditorProps {
surfaceHeight?: number surfaceHeight?: number
/** Whether to show the center handle that moves the entire polygon. */ /** Whether to show the center handle that moves the entire polygon. */
allowPolygonMove?: boolean allowPolygonMove?: boolean
/** Whether polygon edges can be dragged along their perpendicular normal. */
allowEdgeMove?: boolean
} }
/** /**
@@ -35,6 +39,17 @@ export interface PolygonEditorProps {
* Used by zone and site boundary editors * Used by zone and site boundary editors
*/ */
const MIN_HANDLE_HEIGHT = 0.15 const MIN_HANDLE_HEIGHT = 0.15
const EDGE_HANDLE_HEIGHT = 0.06
const EDGE_HANDLE_THICKNESS = 0.12
function getEdgeNormal(start: [number, number], end: [number, number]): [number, number] | null {
const dx = end[0] - start[0]
const dz = end[1] - start[1]
const length = Math.hypot(dx, dz)
if (length < 1e-6) return null
return [-dz / length, dx / length]
}
export const PolygonEditor: React.FC<PolygonEditorProps> = ({ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
polygon, polygon,
@@ -44,6 +59,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
levelId, levelId,
surfaceHeight = 0, surfaceHeight = 0,
allowPolygonMove = false, allowPolygonMove = false,
allowEdgeMove = false,
}) => { }) => {
const [levelNode, setLevelNode] = useState<Object3D | null>(() => const [levelNode, setLevelNode] = useState<Object3D | null>(() =>
levelId ? (sceneRegistry.nodes.get(levelId) ?? null) : null, levelId ? (sceneRegistry.nodes.get(levelId) ?? null) : null,
@@ -89,6 +105,11 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
const [previewPolygon, setPreviewPolygon] = useState<Array<[number, number]> | null>(null) const [previewPolygon, setPreviewPolygon] = useState<Array<[number, number]> | null>(null)
const previewPolygonRef = useRef<Array<[number, number]> | null>(null) const previewPolygonRef = useRef<Array<[number, number]> | null>(null)
const updatePreviewPolygon = useCallback((nextPolygon: Array<[number, number]> | null) => {
previewPolygonRef.current = nextPolygon
setPreviewPolygon(nextPolygon)
}, [])
// Keep ref in sync // Keep ref in sync
useEffect(() => { useEffect(() => {
previewPolygonRef.current = previewPolygon previewPolygonRef.current = previewPolygon
@@ -96,6 +117,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
const [hoveredVertex, setHoveredVertex] = useState<number | null>(null) const [hoveredVertex, setHoveredVertex] = useState<number | null>(null)
const [hoveredMidpoint, setHoveredMidpoint] = useState<number | null>(null) const [hoveredMidpoint, setHoveredMidpoint] = useState<number | null>(null)
const [hoveredEdge, setHoveredEdge] = useState<number | null>(null)
const [cursorPosition, setCursorPosition] = useState<[number, number]>([0, 0]) const [cursorPosition, setCursorPosition] = useState<[number, number]>([0, 0])
const lineRef = useRef<Line>(null!) const lineRef = useRef<Line>(null!)
@@ -106,7 +128,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
if (polygon !== lastPolygonRef.current) { if (polygon !== lastPolygonRef.current) {
lastPolygonRef.current = polygon lastPolygonRef.current = polygon
// External change (e.g. undo/redo) — clear any stale preview/drag state // External change (e.g. undo/redo) — clear any stale preview/drag state
if (previewPolygon) setPreviewPolygon(null) if (previewPolygon) updatePreviewPolygon(null)
if (dragState) setDragState(null) if (dragState) setDragState(null)
} }
@@ -134,17 +156,37 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
}) })
}, [displayPolygon]) }, [displayPolygon])
const edgeHandles = useMemo(() => {
if (displayPolygon.length < 2) return []
return displayPolygon.flatMap(([x1, z1], index) => {
const nextIndex = (index + 1) % displayPolygon.length
const [x2, z2] = displayPolygon[nextIndex]!
const dx = x2 - x1
const dz = z2 - z1
const length = Math.hypot(dx, dz)
if (length < 1e-6) return []
return [
{
index,
length,
midpoint: [(x1 + x2) / 2, (z1 + z2) / 2] as [number, number],
rotationY: -Math.atan2(dz, dx),
},
]
})
}, [displayPolygon])
// Update vertex position using grid cursor position // Update vertex position using grid cursor position
const handleVertexDrag = useCallback( const handleVertexDrag = useCallback(
(vertexIndex: number, position: [number, number]) => { (vertexIndex: number, position: [number, number]) => {
setPreviewPolygon((prev) => { const basePolygon = previewPolygonRef.current ?? polygon
const basePolygon = prev ?? polygon const newPolygon = [...basePolygon]
const newPolygon = [...basePolygon] newPolygon[vertexIndex] = position
newPolygon[vertexIndex] = position updatePreviewPolygon(newPolygon)
return newPolygon
})
}, },
[polygon], [polygon, updatePreviewPolygon],
) )
// Commit polygon changes // Commit polygon changes
@@ -152,9 +194,9 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
if (previewPolygonRef.current) { if (previewPolygonRef.current) {
onPolygonChange(previewPolygonRef.current) onPolygonChange(previewPolygonRef.current)
} }
setPreviewPolygon(null) updatePreviewPolygon(null)
setDragState(null) setDragState(null)
}, [onPolygonChange]) }, [onPolygonChange, updatePreviewPolygon])
// Handle adding a new vertex at midpoint // Handle adding a new vertex at midpoint
const handleAddVertex = useCallback( const handleAddVertex = useCallback(
@@ -166,10 +208,13 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
...basePolygon.slice(afterIndex + 1), ...basePolygon.slice(afterIndex + 1),
] ]
setPreviewPolygon(newPolygon) updatePreviewPolygon(newPolygon)
return afterIndex + 1 // Return new vertex index return {
polygon: newPolygon,
vertexIndex: afterIndex + 1,
}
}, },
[polygon, previewPolygon], [polygon, previewPolygon, updatePreviewPolygon],
) )
// Handle deleting a vertex // Handle deleting a vertex
@@ -180,9 +225,9 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
const newPolygon = basePolygon.filter((_, i) => i !== index) const newPolygon = basePolygon.filter((_, i) => i !== index)
onPolygonChange(newPolygon) onPolygonChange(newPolygon)
setPreviewPolygon(null) updatePreviewPolygon(null)
}, },
[polygon, previewPolygon, onPolygonChange, minVertices], [polygon, previewPolygon, onPolygonChange, minVertices, updatePreviewPolygon],
) )
// Listen to grid:move events to track cursor position // Listen to grid:move events to track cursor position
@@ -212,9 +257,31 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
} else if (dragState.mode === 'polygon') { } else if (dragState.mode === 'polygon') {
const deltaX = newPosition[0] - dragState.initialPosition[0] const deltaX = newPosition[0] - dragState.initialPosition[0]
const deltaZ = newPosition[1] - dragState.initialPosition[1] const deltaZ = newPosition[1] - dragState.initialPosition[1]
setPreviewPolygon( updatePreviewPolygon(
dragState.initialPolygon.map(([x, z]) => [x + deltaX, z + deltaZ] as [number, number]), dragState.initialPolygon.map(([x, z]) => [x + deltaX, z + deltaZ] as [number, number]),
) )
} else if (
dragState.mode === 'edge' &&
dragState.edgeIndex !== undefined &&
dragState.edgeNormal
) {
const [normalX, normalZ] = dragState.edgeNormal
const pointerDeltaX = newPosition[0] - dragState.initialPosition[0]
const pointerDeltaZ = newPosition[1] - dragState.initialPosition[1]
const normalDistance = pointerDeltaX * normalX + pointerDeltaZ * normalZ
const edgeStartIndex = dragState.edgeIndex
const edgeEndIndex = (edgeStartIndex + 1) % dragState.initialPolygon.length
const nextPolygon = dragState.initialPolygon.map((point, index) => {
if (index !== edgeStartIndex && index !== edgeEndIndex) {
return point
}
return [point[0] + normalX * normalDistance, point[1] + normalZ * normalDistance] as [
number,
number,
]
})
updatePreviewPolygon(nextPolygon)
} }
} }
} }
@@ -223,7 +290,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
return () => { return () => {
emitter.off('grid:move', onGridMove) emitter.off('grid:move', onGridMove)
} }
}, [dragState, handleVertexDrag]) }, [dragState, handleVertexDrag, updatePreviewPolygon])
// Set up pointer up listener for ending drag // Set up pointer up listener for ending drag
useEffect(() => { useEffect(() => {
@@ -288,6 +355,8 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
if (displayPolygon.length < minVertices) return null if (displayPolygon.length < minVertices) return null
const canDelete = displayPolygon.length > minVertices const canDelete = displayPolygon.length > minVertices
const handleHeight = Math.max(MIN_HANDLE_HEIGHT, surfaceHeight + 0.02)
const edgeHandleY = editY + handleHeight - EDGE_HANDLE_HEIGHT / 2
const editorContent = ( const editorContent = (
<group> <group>
@@ -316,7 +385,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
const isHovered = hoveredVertex === index const isHovered = hoveredVertex === index
const isDragging = dragState?.mode === 'vertex' && dragState.vertexIndex === index const isDragging = dragState?.mode === 'vertex' && dragState.vertexIndex === index
const radius = 0.1 const radius = 0.1
const height = Math.max(MIN_HANDLE_HEIGHT, surfaceHeight + 0.02) const height = handleHeight
return ( return (
<mesh <mesh
@@ -337,6 +406,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
onPointerDown={(e) => { onPointerDown={(e) => {
if (e.button !== 0) return if (e.button !== 0) return
e.stopPropagation() e.stopPropagation()
setHoveredEdge(null)
setDragState({ setDragState({
isDragging: true, isDragging: true,
mode: 'vertex', mode: 'vertex',
@@ -375,6 +445,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
onPointerDown={(e) => { onPointerDown={(e) => {
if (e.button !== 0) return if (e.button !== 0) return
e.stopPropagation() e.stopPropagation()
setHoveredEdge(null)
setDragState({ setDragState({
isDragging: true, isDragging: true,
mode: 'polygon', mode: 'polygon',
@@ -384,23 +455,75 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
pointerId: e.pointerId, pointerId: e.pointerId,
}) })
}} }}
position={[ position={[polygonCenter[0], editY + handleHeight + 0.08, polygonCenter[1]]}
polygonCenter[0],
editY + Math.max(MIN_HANDLE_HEIGHT, surfaceHeight + 0.02) + 0.08,
polygonCenter[1],
]}
> >
<sphereGeometry args={[0.09, 20, 20]} /> <sphereGeometry args={[0.09, 20, 20]} />
<meshStandardMaterial color={dragState?.mode === 'polygon' ? '#22c55e' : '#f59e0b'} /> <meshStandardMaterial color={dragState?.mode === 'polygon' ? '#22c55e' : '#f59e0b'} />
</mesh> </mesh>
)} )}
{allowEdgeMove &&
edgeHandles.map(({ index, length, midpoint, rotationY }) => {
const isHovered = hoveredEdge === index
const isDragging = dragState?.mode === 'edge' && dragState.edgeIndex === index
return (
<mesh
key={`edge-${index}`}
layers={EDITOR_LAYER}
onClick={(e) => {
if (e.button !== 0) return
e.stopPropagation()
}}
onPointerDown={(e) => {
if (e.button !== 0) return
e.stopPropagation()
const start = displayPolygon[index]
const end = displayPolygon[(index + 1) % displayPolygon.length]
if (!(start && end)) return
const edgeNormal = getEdgeNormal(start, end)
if (!edgeNormal) return
setHoveredEdge(null)
setDragState({
isDragging: true,
mode: 'edge',
vertexIndex: null,
edgeIndex: index,
edgeNormal,
initialPosition: cursorPosition,
initialPolygon: displayPolygon.map(([px, pz]) => [px, pz] as [number, number]),
pointerId: e.pointerId,
})
}}
onPointerEnter={(e) => {
e.stopPropagation()
setHoveredEdge(index)
}}
onPointerLeave={(e) => {
e.stopPropagation()
setHoveredEdge(null)
}}
position={[midpoint[0], edgeHandleY, midpoint[1]]}
rotation={[0, rotationY, 0]}
>
<boxGeometry args={[length, EDGE_HANDLE_HEIGHT, EDGE_HANDLE_THICKNESS]} />
<meshStandardMaterial
color={isDragging ? '#22c55e' : '#94a3b8'}
opacity={isDragging ? 0.5 : isHovered ? 0.38 : 0.14}
transparent
/>
</mesh>
)
})}
{/* Midpoint handles - smaller green cylinders for adding vertices (hidden while dragging) */} {/* Midpoint handles - smaller green cylinders for adding vertices (hidden while dragging) */}
{!dragState && {!dragState &&
midpoints.map(([x, z], index) => { midpoints.map(([x, z], index) => {
const isHovered = hoveredMidpoint === index const isHovered = hoveredMidpoint === index
const radius = 0.06 const radius = 0.06
const height = Math.max(MIN_HANDLE_HEIGHT, surfaceHeight + 0.02) const height = handleHeight
return ( return (
<mesh <mesh
@@ -413,12 +536,14 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
onPointerDown={(e) => { onPointerDown={(e) => {
if (e.button !== 0) return if (e.button !== 0) return
e.stopPropagation() e.stopPropagation()
const newVertexIndex = handleAddVertex(index, [x!, z!]) const insertedVertex = handleAddVertex(index, [x!, z!])
if (newVertexIndex >= 0) { if (insertedVertex.vertexIndex >= 0) {
setDragState({ setDragState({
isDragging: true, isDragging: true,
vertexIndex: newVertexIndex, mode: 'vertex',
vertexIndex: insertedVertex.vertexIndex,
initialPosition: [x!, z!], initialPosition: [x!, z!],
initialPolygon: insertedVertex.polygon,
pointerId: e.pointerId, pointerId: e.pointerId,
}) })
setHoveredMidpoint(null) setHoveredMidpoint(null)
@@ -31,6 +31,7 @@ export const SlabBoundaryEditor: React.FC<SlabBoundaryEditorProps> = ({ slabId }
return ( return (
<PolygonEditor <PolygonEditor
allowEdgeMove
color="#a3a3a3" color="#a3a3a3"
levelId={resolveLevelId(slab, useScene.getState().nodes)} levelId={resolveLevelId(slab, useScene.getState().nodes)}
minVertices={3} minVertices={3}
@@ -36,6 +36,7 @@ export const SlabHoleEditor: React.FC<SlabHoleEditorProps> = ({ slabId, holeInde
return ( return (
<PolygonEditor <PolygonEditor
allowEdgeMove
allowPolygonMove allowPolygonMove
color="#ef4444" color="#ef4444"
levelId={resolveLevelId(slab, useScene.getState().nodes)} // red for holes levelId={resolveLevelId(slab, useScene.getState().nodes)} // red for holes