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:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user