Merge pull request #112 from pascalorg/fix/community-feedback-pass-2
Fix/community feedback pass 2
This commit is contained in:
@@ -34,13 +34,15 @@ function MoveItemContent({ movingNode }: { movingNode: ItemNode }) {
|
|||||||
draftNode,
|
draftNode,
|
||||||
// Duplicates start fresh in floor mode; wall/ceiling draft is created lazily by ensureDraft
|
// Duplicates start fresh in floor mode; wall/ceiling draft is created lazily by ensureDraft
|
||||||
initialState: isNew ? { surface: 'floor', wallId: null, ceilingId: null, surfaceItemId: null } : getInitialState(movingNode),
|
initialState: isNew ? { surface: 'floor', wallId: null, ceilingId: null, surfaceItemId: null } : getInitialState(movingNode),
|
||||||
|
// Preserve the original item's scale so Y-position calculations use the correct height
|
||||||
|
defaultScale: isNew ? movingNode.scale : undefined,
|
||||||
initDraft: (gridPosition) => {
|
initDraft: (gridPosition) => {
|
||||||
if (isNew) {
|
if (isNew) {
|
||||||
// Duplicate: use the same create() path as ItemTool so ghost rendering works correctly.
|
// Duplicate: use the same create() path as ItemTool so ghost rendering works correctly.
|
||||||
// Floor items get a draft immediately; wall/ceiling items are created lazily on surface entry.
|
// Floor items get a draft immediately; wall/ceiling items are created lazily on surface entry.
|
||||||
gridPosition.copy(new Vector3(...movingNode.position))
|
gridPosition.copy(new Vector3(...movingNode.position))
|
||||||
if (!movingNode.asset.attachTo) {
|
if (!movingNode.asset.attachTo) {
|
||||||
draftNode.create(gridPosition, movingNode.asset, movingNode.rotation)
|
draftNode.create(gridPosition, movingNode.asset, movingNode.rotation, movingNode.scale)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
draftNode.adopt(movingNode)
|
draftNode.adopt(movingNode)
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import type {
|
|||||||
WallEvent,
|
WallEvent,
|
||||||
WallNode,
|
WallNode,
|
||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
import { sceneRegistry, useScene } from '@pascal-app/core'
|
import { getScaledDimensions, sceneRegistry, useScene } from '@pascal-app/core'
|
||||||
import { Vector3 } from 'three'
|
import { Vector3 } from 'three'
|
||||||
import type {
|
import type {
|
||||||
CommitResult,
|
CommitResult,
|
||||||
@@ -43,7 +43,7 @@ export const floorStrategy = {
|
|||||||
move(ctx: PlacementContext, event: GridEvent): PlacementResult | null {
|
move(ctx: PlacementContext, event: GridEvent): PlacementResult | null {
|
||||||
if (ctx.state.surface !== 'floor') return null
|
if (ctx.state.surface !== 'floor') return null
|
||||||
|
|
||||||
const dims = ctx.asset.dimensions ?? DEFAULT_DIMENSIONS
|
const dims = ctx.draftItem ? getScaledDimensions(ctx.draftItem) : (ctx.asset.dimensions ?? DEFAULT_DIMENSIONS)
|
||||||
const [dimX, , dimZ] = dims
|
const [dimX, , dimZ] = dims
|
||||||
const x = snapToGrid(event.position[0], dimX)
|
const x = snapToGrid(event.position[0], dimX)
|
||||||
const z = snapToGrid(event.position[2], dimZ)
|
const z = snapToGrid(event.position[2], dimZ)
|
||||||
@@ -70,7 +70,7 @@ export const floorStrategy = {
|
|||||||
const valid = validators.canPlaceOnFloor(
|
const valid = validators.canPlaceOnFloor(
|
||||||
ctx.levelId,
|
ctx.levelId,
|
||||||
pos,
|
pos,
|
||||||
ctx.draftItem.asset.dimensions,
|
getScaledDimensions(ctx.draftItem),
|
||||||
[0, 0, 0],
|
[0, 0, 0],
|
||||||
[ctx.draftItem.id],
|
[ctx.draftItem.id],
|
||||||
).valid
|
).valid
|
||||||
@@ -128,7 +128,7 @@ export const wallStrategy = {
|
|||||||
event.node.id,
|
event.node.id,
|
||||||
x,
|
x,
|
||||||
y,
|
y,
|
||||||
ctx.asset.dimensions ?? DEFAULT_DIMENSIONS,
|
ctx.draftItem ? getScaledDimensions(ctx.draftItem) : (ctx.asset.dimensions ?? DEFAULT_DIMENSIONS),
|
||||||
attachTo,
|
attachTo,
|
||||||
side,
|
side,
|
||||||
[],
|
[],
|
||||||
@@ -179,7 +179,7 @@ export const wallStrategy = {
|
|||||||
event.node.id,
|
event.node.id,
|
||||||
snappedX,
|
snappedX,
|
||||||
snappedY,
|
snappedY,
|
||||||
ctx.draftItem.asset.dimensions,
|
getScaledDimensions(ctx.draftItem),
|
||||||
ctx.draftItem.asset.attachTo as 'wall' | 'wall-side',
|
ctx.draftItem.asset.attachTo as 'wall' | 'wall-side',
|
||||||
side,
|
side,
|
||||||
[ctx.draftItem.id],
|
[ctx.draftItem.id],
|
||||||
@@ -219,7 +219,7 @@ export const wallStrategy = {
|
|||||||
ctx.state.wallId as WallNode['id'],
|
ctx.state.wallId as WallNode['id'],
|
||||||
ctx.gridPosition.x,
|
ctx.gridPosition.x,
|
||||||
ctx.gridPosition.y,
|
ctx.gridPosition.y,
|
||||||
ctx.draftItem.asset.dimensions,
|
getScaledDimensions(ctx.draftItem),
|
||||||
ctx.draftItem.asset.attachTo as 'wall' | 'wall-side',
|
ctx.draftItem.asset.attachTo as 'wall' | 'wall-side',
|
||||||
ctx.draftItem.side,
|
ctx.draftItem.side,
|
||||||
[ctx.draftItem.id],
|
[ctx.draftItem.id],
|
||||||
@@ -281,7 +281,7 @@ export const ceilingStrategy = {
|
|||||||
const ceilingLevelId = resolveLevelId(event.node, nodes)
|
const ceilingLevelId = resolveLevelId(event.node, nodes)
|
||||||
if (ctx.levelId !== ceilingLevelId) return null
|
if (ctx.levelId !== ceilingLevelId) return null
|
||||||
|
|
||||||
const dims = ctx.asset.dimensions ?? DEFAULT_DIMENSIONS
|
const dims = ctx.draftItem ? getScaledDimensions(ctx.draftItem) : (ctx.asset.dimensions ?? DEFAULT_DIMENSIONS)
|
||||||
const [dimX, , dimZ] = dims
|
const [dimX, , dimZ] = dims
|
||||||
const itemHeight = dims[1]
|
const itemHeight = dims[1]
|
||||||
|
|
||||||
@@ -308,7 +308,7 @@ export const ceilingStrategy = {
|
|||||||
if (ctx.state.surface !== 'ceiling') return null
|
if (ctx.state.surface !== 'ceiling') return null
|
||||||
if (!ctx.draftItem) return null
|
if (!ctx.draftItem) return null
|
||||||
|
|
||||||
const dims = ctx.asset.dimensions ?? DEFAULT_DIMENSIONS
|
const dims = getScaledDimensions(ctx.draftItem)
|
||||||
const [dimX, , dimZ] = dims
|
const [dimX, , dimZ] = dims
|
||||||
const itemHeight = dims[1]
|
const itemHeight = dims[1]
|
||||||
|
|
||||||
@@ -341,7 +341,7 @@ export const ceilingStrategy = {
|
|||||||
const valid = validators.canPlaceOnCeiling(
|
const valid = validators.canPlaceOnCeiling(
|
||||||
ctx.state.ceilingId as CeilingNode['id'],
|
ctx.state.ceilingId as CeilingNode['id'],
|
||||||
pos,
|
pos,
|
||||||
ctx.draftItem.asset.dimensions,
|
getScaledDimensions(ctx.draftItem),
|
||||||
ctx.draftItem.rotation,
|
ctx.draftItem.rotation,
|
||||||
[ctx.draftItem.id],
|
[ctx.draftItem.id],
|
||||||
).valid
|
).valid
|
||||||
@@ -399,8 +399,8 @@ export const itemSurfaceStrategy = {
|
|||||||
if (!surfaceItem.asset.surface) return null
|
if (!surfaceItem.asset.surface) 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.asset.dimensions ?? DEFAULT_DIMENSIONS
|
const ourDims = ctx.draftItem ? getScaledDimensions(ctx.draftItem) : (ctx.asset.dimensions ?? DEFAULT_DIMENSIONS)
|
||||||
const surfDims = surfaceItem.asset.dimensions
|
const surfDims = getScaledDimensions(surfaceItem)
|
||||||
if (ourDims[0] > surfDims[0] || ourDims[2] > surfDims[2]) return null
|
if (ourDims[0] > surfDims[0] || ourDims[2] > surfDims[2]) return null
|
||||||
|
|
||||||
const surfaceMesh = sceneRegistry.nodes.get(surfaceItem.id)
|
const surfaceMesh = sceneRegistry.nodes.get(surfaceItem.id)
|
||||||
@@ -411,7 +411,7 @@ export const itemSurfaceStrategy = {
|
|||||||
|
|
||||||
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
|
const y = surfaceItem.asset.surface.height * surfaceItem.scale[1]
|
||||||
|
|
||||||
const worldSnapped = surfaceMesh.localToWorld(new Vector3(x, y, z))
|
const worldSnapped = surfaceMesh.localToWorld(new Vector3(x, y, z))
|
||||||
|
|
||||||
@@ -439,13 +439,13 @@ export const itemSurfaceStrategy = {
|
|||||||
const surfaceMesh = sceneRegistry.nodes.get(ctx.state.surfaceItemId)
|
const surfaceMesh = sceneRegistry.nodes.get(ctx.state.surfaceItemId)
|
||||||
if (!surfaceMesh) return null
|
if (!surfaceMesh) return null
|
||||||
|
|
||||||
const ourDims = ctx.asset.dimensions ?? DEFAULT_DIMENSIONS
|
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 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
|
const y = surfaceItem.asset.surface.height * surfaceItem.scale[1]
|
||||||
|
|
||||||
const worldSnapped = surfaceMesh.localToWorld(new Vector3(x, y, z))
|
const worldSnapped = surfaceMesh.localToWorld(new Vector3(x, y, z))
|
||||||
|
|
||||||
@@ -501,7 +501,7 @@ export function checkCanPlace(ctx: PlacementContext, validators: SpatialValidato
|
|||||||
return validators.canPlaceOnCeiling(
|
return validators.canPlaceOnCeiling(
|
||||||
ctx.state.ceilingId as CeilingNode['id'],
|
ctx.state.ceilingId as CeilingNode['id'],
|
||||||
[ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z],
|
[ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z],
|
||||||
ctx.draftItem.asset.dimensions,
|
getScaledDimensions(ctx.draftItem),
|
||||||
ctx.draftItem.rotation,
|
ctx.draftItem.rotation,
|
||||||
[ctx.draftItem.id],
|
[ctx.draftItem.id],
|
||||||
).valid
|
).valid
|
||||||
@@ -514,7 +514,7 @@ export function checkCanPlace(ctx: PlacementContext, validators: SpatialValidato
|
|||||||
ctx.state.wallId as WallNode['id'],
|
ctx.state.wallId as WallNode['id'],
|
||||||
ctx.gridPosition.x,
|
ctx.gridPosition.x,
|
||||||
ctx.gridPosition.y,
|
ctx.gridPosition.y,
|
||||||
ctx.draftItem.asset.dimensions,
|
getScaledDimensions(ctx.draftItem),
|
||||||
attachTo,
|
attachTo,
|
||||||
ctx.draftItem.side,
|
ctx.draftItem.side,
|
||||||
[ctx.draftItem.id],
|
[ctx.draftItem.id],
|
||||||
@@ -525,7 +525,7 @@ export function checkCanPlace(ctx: PlacementContext, validators: SpatialValidato
|
|||||||
return validators.canPlaceOnFloor(
|
return validators.canPlaceOnFloor(
|
||||||
ctx.levelId,
|
ctx.levelId,
|
||||||
[ctx.gridPosition.x, 0, ctx.gridPosition.z],
|
[ctx.gridPosition.x, 0, ctx.gridPosition.z],
|
||||||
ctx.draftItem.asset.dimensions,
|
getScaledDimensions(ctx.draftItem),
|
||||||
[0, 0, 0],
|
[0, 0, 0],
|
||||||
[ctx.draftItem.id],
|
[ctx.draftItem.id],
|
||||||
).valid
|
).valid
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export interface DraftNodeHandle {
|
|||||||
/** Whether the current draft was adopted (move mode) vs created (create mode) */
|
/** Whether the current draft was adopted (move mode) vs created (create mode) */
|
||||||
readonly isAdopted: boolean
|
readonly isAdopted: boolean
|
||||||
/** Create a new draft item at the given position. Returns the created node or null. */
|
/** Create a new draft item at the given position. Returns the created node or null. */
|
||||||
create: (gridPosition: Vector3, asset: AssetInput, rotation?: [number, number, number]) => ItemNode | null
|
create: (gridPosition: Vector3, asset: AssetInput, rotation?: [number, number, number], scale?: [number, number, number]) => ItemNode | null
|
||||||
/** Take ownership of an existing scene node as the draft (for move mode). */
|
/** Take ownership of an existing scene node as the draft (for move mode). */
|
||||||
adopt: (node: ItemNode) => void
|
adopt: (node: ItemNode) => void
|
||||||
/** Commit the current draft. Create mode: delete+recreate. Move mode: update in place. */
|
/** Commit the current draft. Create mode: delete+recreate. Move mode: update in place. */
|
||||||
@@ -40,13 +40,14 @@ export function useDraftNode(): DraftNodeHandle {
|
|||||||
const adoptedRef = useRef(false)
|
const adoptedRef = useRef(false)
|
||||||
const originalStateRef = useRef<OriginalState | null>(null)
|
const originalStateRef = useRef<OriginalState | null>(null)
|
||||||
|
|
||||||
const create = useCallback((gridPosition: Vector3, asset: AssetInput, rotation?: [number, number, number]): ItemNode | null => {
|
const create = useCallback((gridPosition: Vector3, asset: AssetInput, rotation?: [number, number, number], scale?: [number, number, number]): ItemNode | null => {
|
||||||
const currentLevelId = useViewer.getState().selection.levelId
|
const currentLevelId = useViewer.getState().selection.levelId
|
||||||
if (!currentLevelId) return null
|
if (!currentLevelId) return null
|
||||||
|
|
||||||
const node = ItemNode.parse({
|
const node = ItemNode.parse({
|
||||||
position: [gridPosition.x, gridPosition.y, gridPosition.z],
|
position: [gridPosition.x, gridPosition.y, gridPosition.z],
|
||||||
rotation: rotation ?? [0, 0, 0],
|
rotation: rotation ?? [0, 0, 0],
|
||||||
|
scale: scale ?? [1, 1, 1],
|
||||||
name: asset.name,
|
name: asset.name,
|
||||||
asset,
|
asset,
|
||||||
parentId: currentLevelId,
|
parentId: currentLevelId,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
type AnyNodeId,
|
type AnyNodeId,
|
||||||
type CeilingEvent,
|
type CeilingEvent,
|
||||||
emitter,
|
emitter,
|
||||||
|
getScaledDimensions,
|
||||||
type GridEvent,
|
type GridEvent,
|
||||||
type ItemEvent,
|
type ItemEvent,
|
||||||
resolveLevelId,
|
resolveLevelId,
|
||||||
@@ -65,6 +66,8 @@ export interface PlacementCoordinatorConfig {
|
|||||||
onCommitted: () => boolean
|
onCommitted: () => boolean
|
||||||
onCancel?: () => void
|
onCancel?: () => void
|
||||||
initialState?: PlacementState
|
initialState?: PlacementState
|
||||||
|
/** Scale to use when lazily creating a draft (e.g. for wall/ceiling duplicates). Defaults to [1,1,1]. */
|
||||||
|
defaultScale?: [number, number, number]
|
||||||
}
|
}
|
||||||
|
|
||||||
export function usePlacementCoordinator(config: PlacementCoordinatorConfig): React.ReactNode {
|
export function usePlacementCoordinator(config: PlacementCoordinatorConfig): React.ReactNode {
|
||||||
@@ -139,7 +142,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
cursorGroupRef.current.position.set(...result.cursorPosition)
|
cursorGroupRef.current.position.set(...result.cursorPosition)
|
||||||
cursorGroupRef.current.rotation.y = result.cursorRotationY
|
cursorGroupRef.current.rotation.y = result.cursorRotationY
|
||||||
|
|
||||||
draftNode.create(gridPosition.current, asset, [0, result.cursorRotationY, 0])
|
draftNode.create(gridPosition.current, asset, [0, result.cursorRotationY, 0], configRef.current.defaultScale)
|
||||||
|
|
||||||
const draft = draftNode.current
|
const draft = draftNode.current
|
||||||
if (draft) {
|
if (draft) {
|
||||||
@@ -646,7 +649,8 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
|
|
||||||
// ---- Bounding box geometry ----
|
// ---- Bounding box geometry ----
|
||||||
|
|
||||||
const dims = asset.dimensions ?? DEFAULT_DIMENSIONS
|
const draft = draftNode.current
|
||||||
|
const dims = draft ? getScaledDimensions(draft) : (asset.dimensions ?? DEFAULT_DIMENSIONS)
|
||||||
const boxGeometry = new BoxGeometry(dims[0], dims[1], dims[2])
|
const boxGeometry = new BoxGeometry(dims[0], dims[1], dims[2])
|
||||||
boxGeometry.translate(0, dims[1] / 2, 0)
|
boxGeometry.translate(0, dims[1] / 2, 0)
|
||||||
const edgesGeometry = new EdgesGeometry(boxGeometry)
|
const edgesGeometry = new EdgesGeometry(boxGeometry)
|
||||||
@@ -731,7 +735,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
const slabElevation = spatialGridManager.getSlabElevationForItem(
|
const slabElevation = spatialGridManager.getSlabElevationForItem(
|
||||||
levelId,
|
levelId,
|
||||||
[gridPosition.current.x, gridPosition.current.y, gridPosition.current.z],
|
[gridPosition.current.x, gridPosition.current.y, gridPosition.current.z],
|
||||||
asset.dimensions ?? DEFAULT_DIMENSIONS,
|
getScaledDimensions(draftNode.current),
|
||||||
draftNode.current.rotation,
|
draftNode.current.rotation,
|
||||||
)
|
)
|
||||||
mesh.position.y = slabElevation
|
mesh.position.y = slabElevation
|
||||||
@@ -743,7 +747,8 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const dims = config.asset.dimensions ?? DEFAULT_DIMENSIONS
|
const initialDraft = draftNode.current
|
||||||
|
const dims = initialDraft ? getScaledDimensions(initialDraft) : (config.asset.dimensions ?? DEFAULT_DIMENSIONS)
|
||||||
const initialBoxGeometry = new BoxGeometry(dims[0], dims[1], dims[2])
|
const initialBoxGeometry = new BoxGeometry(dims[0], dims[1], dims[2])
|
||||||
initialBoxGeometry.translate(0, dims[1] / 2, 0)
|
initialBoxGeometry.translate(0, dims[1] / 2, 0)
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
type AnyNodeId,
|
type AnyNodeId,
|
||||||
emitter,
|
emitter,
|
||||||
sceneRegistry,
|
sceneRegistry,
|
||||||
|
spatialGridManager,
|
||||||
useScene,
|
useScene,
|
||||||
type WallEvent,
|
type WallEvent,
|
||||||
WindowNode,
|
WindowNode,
|
||||||
@@ -78,10 +79,17 @@ export const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWin
|
|||||||
if (wallId) useScene.getState().dirtyNodes.add(wallId as AnyNodeId)
|
if (wallId) useScene.getState().dirtyNodes.add(wallId as AnyNodeId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getLevelId = () => useViewer.getState().selection.levelId
|
||||||
const getLevelYOffset = () => {
|
const getLevelYOffset = () => {
|
||||||
const id = useViewer.getState().selection.levelId
|
const id = getLevelId()
|
||||||
return id ? (sceneRegistry.nodes.get(id as AnyNodeId)?.position.y ?? 0) : 0
|
return id ? (sceneRegistry.nodes.get(id as AnyNodeId)?.position.y ?? 0) : 0
|
||||||
}
|
}
|
||||||
|
const getSlabElevation = (wallEvent: WallEvent) =>
|
||||||
|
spatialGridManager.getSlabElevationForWall(
|
||||||
|
wallEvent.node.parentId ?? '',
|
||||||
|
wallEvent.node.start,
|
||||||
|
wallEvent.node.end,
|
||||||
|
)
|
||||||
|
|
||||||
const hideCursor = () => {
|
const hideCursor = () => {
|
||||||
if (cursorGroupRef.current) cursorGroupRef.current.visible = false
|
if (cursorGroupRef.current) cursorGroupRef.current.visible = false
|
||||||
@@ -102,6 +110,8 @@ export const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWin
|
|||||||
|
|
||||||
const onWallEnter = (event: WallEvent) => {
|
const onWallEnter = (event: WallEvent) => {
|
||||||
if (!isValidWallSideFace(event.normal)) return
|
if (!isValidWallSideFace(event.normal)) return
|
||||||
|
// Only interact with walls on the current level
|
||||||
|
if (event.node.parentId !== getLevelId()) return
|
||||||
|
|
||||||
const side = getSideFromNormal(event.normal)
|
const side = getSideFromNormal(event.normal)
|
||||||
const itemRotation = calculateItemRotation(event.normal)
|
const itemRotation = calculateItemRotation(event.normal)
|
||||||
@@ -134,12 +144,18 @@ export const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWin
|
|||||||
movingWindowNode.id,
|
movingWindowNode.id,
|
||||||
)
|
)
|
||||||
|
|
||||||
updateCursor(wallLocalToWorld(event.node, clampedX, clampedY, getLevelYOffset()), cursorRotation, valid)
|
updateCursor(
|
||||||
|
wallLocalToWorld(event.node, clampedX, clampedY, getLevelYOffset(), getSlabElevation(event)),
|
||||||
|
cursorRotation,
|
||||||
|
valid,
|
||||||
|
)
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
}
|
}
|
||||||
|
|
||||||
const onWallMove = (event: WallEvent) => {
|
const onWallMove = (event: WallEvent) => {
|
||||||
if (!isValidWallSideFace(event.normal)) return
|
if (!isValidWallSideFace(event.normal)) return
|
||||||
|
// Only interact with walls on the current level
|
||||||
|
if (event.node.parentId !== getLevelId()) return
|
||||||
|
|
||||||
const side = getSideFromNormal(event.normal)
|
const side = getSideFromNormal(event.normal)
|
||||||
const itemRotation = calculateItemRotation(event.normal)
|
const itemRotation = calculateItemRotation(event.normal)
|
||||||
@@ -172,12 +188,18 @@ export const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWin
|
|||||||
movingWindowNode.id,
|
movingWindowNode.id,
|
||||||
)
|
)
|
||||||
|
|
||||||
updateCursor(wallLocalToWorld(event.node, clampedX, clampedY, getLevelYOffset()), cursorRotation, valid)
|
updateCursor(
|
||||||
|
wallLocalToWorld(event.node, clampedX, clampedY, getLevelYOffset(), getSlabElevation(event)),
|
||||||
|
cursorRotation,
|
||||||
|
valid,
|
||||||
|
)
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
}
|
}
|
||||||
|
|
||||||
const onWallClick = (event: WallEvent) => {
|
const onWallClick = (event: WallEvent) => {
|
||||||
if (!isValidWallSideFace(event.normal)) return
|
if (!isValidWallSideFace(event.normal)) return
|
||||||
|
// Only interact with walls on the current level
|
||||||
|
if (event.node.parentId !== getLevelId()) return
|
||||||
|
|
||||||
const side = getSideFromNormal(event.normal)
|
const side = getSideFromNormal(event.normal)
|
||||||
const itemRotation = calculateItemRotation(event.normal)
|
const itemRotation = calculateItemRotation(event.normal)
|
||||||
|
|||||||
@@ -1,16 +1,18 @@
|
|||||||
import { type AnyNodeId, type ItemNode, useScene, type WallNode, type WindowNode } from '@pascal-app/core'
|
import { getScaledDimensions, type AnyNodeId, type ItemNode, useScene, type WallNode, type WindowNode } from '@pascal-app/core'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Converts wall-local (X along wall, Y = height above level floor) to world XYZ.
|
* Converts wall-local (X along wall, Y = height above wall base) to world XYZ.
|
||||||
* Wall XZ uses level-local coordinates (levels only offset in Y, not XZ).
|
* Wall XZ uses level-local coordinates (levels only offset in Y, not XZ).
|
||||||
* Pass levelYOffset (the level group's current world Y) so the cursor lands at the
|
* Pass levelYOffset (the level group's current world Y) and slabElevation (the
|
||||||
* correct world height when the cursor group is at the scene root.
|
* wall mesh's Y within the level group) so the cursor lands at the correct world
|
||||||
|
* height — matching how WallSystem positions the wall mesh at slabElevation.
|
||||||
*/
|
*/
|
||||||
export function wallLocalToWorld(
|
export function wallLocalToWorld(
|
||||||
wallNode: WallNode,
|
wallNode: WallNode,
|
||||||
localX: number,
|
localX: number,
|
||||||
localY: number,
|
localY: number,
|
||||||
levelYOffset = 0,
|
levelYOffset = 0,
|
||||||
|
slabElevation = 0,
|
||||||
): [number, number, number] {
|
): [number, number, number] {
|
||||||
const wallAngle = Math.atan2(
|
const wallAngle = Math.atan2(
|
||||||
wallNode.end[1] - wallNode.start[1],
|
wallNode.end[1] - wallNode.start[1],
|
||||||
@@ -18,7 +20,7 @@ export function wallLocalToWorld(
|
|||||||
)
|
)
|
||||||
return [
|
return [
|
||||||
wallNode.start[0] + localX * Math.cos(wallAngle),
|
wallNode.start[0] + localX * Math.cos(wallAngle),
|
||||||
localY + levelYOffset,
|
slabElevation + localY + levelYOffset,
|
||||||
wallNode.start[1] + localX * Math.sin(wallAngle),
|
wallNode.start[1] + localX * Math.sin(wallAngle),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -77,7 +79,7 @@ export function hasWallChildOverlap(
|
|||||||
if (child.type === 'item') {
|
if (child.type === 'item') {
|
||||||
const item = child as ItemNode
|
const item = child as ItemNode
|
||||||
if (item.asset.attachTo !== 'wall' && item.asset.attachTo !== 'wall-side') continue
|
if (item.asset.attachTo !== 'wall' && item.asset.attachTo !== 'wall-side') continue
|
||||||
const [w, h] = item.asset.dimensions
|
const [w, h] = getScaledDimensions(item)
|
||||||
childLeft = item.position[0] - w / 2
|
childLeft = item.position[0] - w / 2
|
||||||
childRight = item.position[0] + w / 2
|
childRight = item.position[0] + w / 2
|
||||||
childBottom = item.position[1] // items store bottom Y
|
childBottom = item.position[1] // items store bottom Y
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
type AnyNodeId,
|
type AnyNodeId,
|
||||||
emitter,
|
emitter,
|
||||||
sceneRegistry,
|
sceneRegistry,
|
||||||
|
spatialGridManager,
|
||||||
useScene,
|
useScene,
|
||||||
type WallEvent,
|
type WallEvent,
|
||||||
WindowNode,
|
WindowNode,
|
||||||
@@ -44,6 +45,12 @@ export const WindowTool: React.FC = () => {
|
|||||||
const id = getLevelId()
|
const id = getLevelId()
|
||||||
return id ? (sceneRegistry.nodes.get(id as AnyNodeId)?.position.y ?? 0) : 0
|
return id ? (sceneRegistry.nodes.get(id as AnyNodeId)?.position.y ?? 0) : 0
|
||||||
}
|
}
|
||||||
|
const getSlabElevation = (wallEvent: WallEvent) =>
|
||||||
|
spatialGridManager.getSlabElevationForWall(
|
||||||
|
wallEvent.node.parentId ?? '',
|
||||||
|
wallEvent.node.start,
|
||||||
|
wallEvent.node.end,
|
||||||
|
)
|
||||||
|
|
||||||
const markWallDirty = (wallId: string) => {
|
const markWallDirty = (wallId: string) => {
|
||||||
useScene.getState().dirtyNodes.add(wallId as AnyNodeId)
|
useScene.getState().dirtyNodes.add(wallId as AnyNodeId)
|
||||||
@@ -79,6 +86,8 @@ export const WindowTool: React.FC = () => {
|
|||||||
if (!isValidWallSideFace(event.normal)) return
|
if (!isValidWallSideFace(event.normal)) return
|
||||||
const levelId = getLevelId()
|
const levelId = getLevelId()
|
||||||
if (!levelId) return
|
if (!levelId) return
|
||||||
|
// Only interact with walls on the current level
|
||||||
|
if (event.node.parentId !== levelId) return
|
||||||
|
|
||||||
destroyDraft()
|
destroyDraft()
|
||||||
|
|
||||||
@@ -108,12 +117,18 @@ export const WindowTool: React.FC = () => {
|
|||||||
|
|
||||||
const valid = !hasWallChildOverlap(event.node.id, clampedX, clampedY, width, height, node.id)
|
const valid = !hasWallChildOverlap(event.node.id, clampedX, clampedY, width, height, node.id)
|
||||||
|
|
||||||
updateCursor(wallLocalToWorld(event.node, clampedX, clampedY, getLevelYOffset()), cursorRotation, valid)
|
updateCursor(
|
||||||
|
wallLocalToWorld(event.node, clampedX, clampedY, getLevelYOffset(), getSlabElevation(event)),
|
||||||
|
cursorRotation,
|
||||||
|
valid,
|
||||||
|
)
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
}
|
}
|
||||||
|
|
||||||
const onWallMove = (event: WallEvent) => {
|
const onWallMove = (event: WallEvent) => {
|
||||||
if (!isValidWallSideFace(event.normal)) return
|
if (!isValidWallSideFace(event.normal)) return
|
||||||
|
// Only interact with walls on the current level
|
||||||
|
if (event.node.parentId !== getLevelId()) return
|
||||||
|
|
||||||
const side = getSideFromNormal(event.normal)
|
const side = getSideFromNormal(event.normal)
|
||||||
const itemRotation = calculateItemRotation(event.normal)
|
const itemRotation = calculateItemRotation(event.normal)
|
||||||
@@ -142,13 +157,19 @@ export const WindowTool: React.FC = () => {
|
|||||||
draftRef.current?.id,
|
draftRef.current?.id,
|
||||||
)
|
)
|
||||||
|
|
||||||
updateCursor(wallLocalToWorld(event.node, clampedX, clampedY, getLevelYOffset()), cursorRotation, valid)
|
updateCursor(
|
||||||
|
wallLocalToWorld(event.node, clampedX, clampedY, getLevelYOffset(), getSlabElevation(event)),
|
||||||
|
cursorRotation,
|
||||||
|
valid,
|
||||||
|
)
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
}
|
}
|
||||||
|
|
||||||
const onWallClick = (event: WallEvent) => {
|
const onWallClick = (event: WallEvent) => {
|
||||||
if (!draftRef.current) return
|
if (!draftRef.current) return
|
||||||
if (!isValidWallSideFace(event.normal)) return
|
if (!isValidWallSideFace(event.normal)) return
|
||||||
|
// Only interact with walls on the current level
|
||||||
|
if (event.node.parentId !== getLevelId()) return
|
||||||
|
|
||||||
const side = getSideFromNormal(event.normal)
|
const side = getSideFromNormal(event.normal)
|
||||||
const itemRotation = calculateItemRotation(event.normal)
|
const itemRotation = calculateItemRotation(event.normal)
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { type AnyNode, ItemNode, useScene } from '@pascal-app/core'
|
import { getScaledDimensions, type AnyNode, ItemNode, useScene } from '@pascal-app/core'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { Copy, Move, Trash2, X } from 'lucide-react'
|
import { Copy, Link, Link2Off, Move, Trash2, X } from 'lucide-react'
|
||||||
import Image from 'next/image'
|
import Image from 'next/image'
|
||||||
import { useCallback } from 'react'
|
import { useCallback, useState } from 'react'
|
||||||
import useEditor from '@/store/use-editor'
|
import useEditor from '@/store/use-editor'
|
||||||
import { NumberInput } from '@/components/ui/primitives/number-input'
|
import { NumberInput } from '@/components/ui/primitives/number-input'
|
||||||
import { sfxEmitter } from '@/lib/sfx-bus'
|
import { sfxEmitter } from '@/lib/sfx-bus'
|
||||||
@@ -23,6 +23,8 @@ export function ItemPanel() {
|
|||||||
? (nodes[selectedId as AnyNode['id']] as ItemNode | undefined)
|
? (nodes[selectedId as AnyNode['id']] as ItemNode | undefined)
|
||||||
: undefined
|
: undefined
|
||||||
|
|
||||||
|
const [uniformScale, setUniformScale] = useState(true)
|
||||||
|
|
||||||
const handleUpdate = useCallback(
|
const handleUpdate = useCallback(
|
||||||
(updates: Partial<ItemNode>) => {
|
(updates: Partial<ItemNode>) => {
|
||||||
if (!selectedId || !node) return
|
if (!selectedId || !node) return
|
||||||
@@ -188,13 +190,75 @@ export function ItemPanel() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Dimensions (read-only) */}
|
{/* Scale */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<label className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
|
||||||
|
Scale
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground cursor-pointer"
|
||||||
|
onClick={() => setUniformScale((v) => !v)}
|
||||||
|
title={uniformScale ? 'Unlock axes' : 'Lock axes'}
|
||||||
|
>
|
||||||
|
{uniformScale ? <Link className="h-3.5 w-3.5" /> : <Link2Off className="h-3.5 w-3.5" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{uniformScale ? (
|
||||||
|
<NumberInput
|
||||||
|
label="XYZ"
|
||||||
|
value={Math.round(node.scale[0] * 100) / 100}
|
||||||
|
onChange={(value) => {
|
||||||
|
const v = Math.max(0.01, value)
|
||||||
|
handleUpdate({ scale: [v, v, v] })
|
||||||
|
}}
|
||||||
|
precision={2}
|
||||||
|
step={0.1}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
<NumberInput
|
||||||
|
label="X"
|
||||||
|
value={Math.round(node.scale[0] * 100) / 100}
|
||||||
|
onChange={(value) => {
|
||||||
|
handleUpdate({ scale: [Math.max(0.01, value), node.scale[1], node.scale[2]] })
|
||||||
|
}}
|
||||||
|
precision={2}
|
||||||
|
step={0.1}
|
||||||
|
/>
|
||||||
|
<NumberInput
|
||||||
|
label="Y"
|
||||||
|
value={Math.round(node.scale[1] * 100) / 100}
|
||||||
|
onChange={(value) => {
|
||||||
|
handleUpdate({ scale: [node.scale[0], Math.max(0.01, value), node.scale[2]] })
|
||||||
|
}}
|
||||||
|
precision={2}
|
||||||
|
step={0.1}
|
||||||
|
/>
|
||||||
|
<NumberInput
|
||||||
|
label="Z"
|
||||||
|
value={Math.round(node.scale[2] * 100) / 100}
|
||||||
|
onChange={(value) => {
|
||||||
|
handleUpdate({ scale: [node.scale[0], node.scale[1], Math.max(0.01, value)] })
|
||||||
|
}}
|
||||||
|
precision={2}
|
||||||
|
step={0.1}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Dimensions (effective, read-only) */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
|
<label className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
|
||||||
Dimensions
|
Dimensions
|
||||||
</label>
|
</label>
|
||||||
<div className="rounded border border-border bg-muted/50 px-3 py-2 text-sm">
|
<div className="rounded border border-border bg-muted/50 px-3 py-2 text-sm">
|
||||||
{node.asset.dimensions[0]}m × {node.asset.dimensions[1]}m × {node.asset.dimensions[2]}m
|
{(() => {
|
||||||
|
const [w, h, d] = getScaledDimensions(node)
|
||||||
|
return `${Math.round(w * 100) / 100}m × ${Math.round(h * 100) / 100}m × ${Math.round(d * 100) / 100}m`
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { getScaledDimensions } from '../../schema'
|
||||||
import type { AnyNode, CeilingNode, ItemNode, SlabNode, WallNode } from '../../schema'
|
import type { AnyNode, CeilingNode, ItemNode, SlabNode, WallNode } from '../../schema'
|
||||||
import { SpatialGrid } from './spatial-grid'
|
import { SpatialGrid } from './spatial-grid'
|
||||||
import { WallSpatialGrid } from './wall-spatial-grid'
|
import { WallSpatialGrid } from './wall-spatial-grid'
|
||||||
@@ -183,11 +184,24 @@ export function wallOverlapsPolygon(
|
|||||||
end: [number, number],
|
end: [number, number],
|
||||||
polygon: Array<[number, number]>,
|
polygon: Array<[number, number]>,
|
||||||
): boolean {
|
): boolean {
|
||||||
const startInside = pointInPolygon(start[0], start[1], polygon)
|
const dx = end[0] - start[0]
|
||||||
const endInside = pointInPolygon(end[0], end[1], polygon)
|
const dz = end[1] - start[1]
|
||||||
|
const len = Math.sqrt(dx * dx + dz * dz)
|
||||||
|
|
||||||
// At least one endpoint strictly inside the polygon
|
// Nudge endpoint test points a tiny step inward along the wall direction before
|
||||||
if (startInside || endInside) return true
|
// testing containment. pointInPolygon (ray casting) produces false positives for
|
||||||
|
// points exactly on polygon vertices or edges — specifically the minimum-z corner
|
||||||
|
// of an axis-aligned polygon returns "inside" because the ray hits the opposite
|
||||||
|
// vertical edge exactly at its base. Nudging by 1e-6 m avoids this: a wall that
|
||||||
|
// merely starts at a slab corner and extends outward will have its nudged point
|
||||||
|
// clearly outside, while a wall that genuinely starts inside stays inside.
|
||||||
|
if (len > 1e-10) {
|
||||||
|
const step = Math.min(1e-6, len * 0.01)
|
||||||
|
const nx = (dx / len) * step
|
||||||
|
const nz = (dz / len) * step
|
||||||
|
if (pointInPolygon(start[0] + nx, start[1] + nz, polygon)) return true
|
||||||
|
if (pointInPolygon(end[0] - nx, end[1] - nz, polygon)) return true
|
||||||
|
}
|
||||||
|
|
||||||
// Check if midpoint is inside (catches walls crossing through)
|
// Check if midpoint is inside (catches walls crossing through)
|
||||||
const midX = (start[0] + end[0]) / 2
|
const midX = (start[0] + end[0]) / 2
|
||||||
@@ -278,7 +292,7 @@ export class SpatialGridManager {
|
|||||||
if (wallId && this.walls.has(wallId)) {
|
if (wallId && this.walls.has(wallId)) {
|
||||||
const wallLength = this.getWallLength(wallId)
|
const wallLength = this.getWallLength(wallId)
|
||||||
if (wallLength > 0) {
|
if (wallLength > 0) {
|
||||||
const [width, height] = item.asset.dimensions
|
const [width, height] = getScaledDimensions(item)
|
||||||
const halfW = width / wallLength / 2
|
const halfW = width / wallLength / 2
|
||||||
// Calculate t from local X position (position[0] is distance along wall)
|
// Calculate t from local X position (position[0] is distance along wall)
|
||||||
const t = item.position[0] / wallLength
|
const t = item.position[0] / wallLength
|
||||||
@@ -299,7 +313,7 @@ export class SpatialGridManager {
|
|||||||
// Ceiling item - use parentId as the ceiling ID
|
// Ceiling item - use parentId as the ceiling ID
|
||||||
const ceilingId = item.parentId
|
const ceilingId = item.parentId
|
||||||
if (ceilingId && this.ceilings.has(ceilingId)) {
|
if (ceilingId && this.ceilings.has(ceilingId)) {
|
||||||
this.getCeilingGrid(ceilingId).insert(item.id, item.position, item.asset.dimensions, item.rotation)
|
this.getCeilingGrid(ceilingId).insert(item.id, item.position, getScaledDimensions(item), item.rotation)
|
||||||
this.itemCeilingMap.set(item.id, ceilingId)
|
this.itemCeilingMap.set(item.id, ceilingId)
|
||||||
}
|
}
|
||||||
} else if (!item.asset.attachTo) {
|
} else if (!item.asset.attachTo) {
|
||||||
@@ -307,7 +321,7 @@ export class SpatialGridManager {
|
|||||||
this.getFloorGrid(levelId).insert(
|
this.getFloorGrid(levelId).insert(
|
||||||
item.id,
|
item.id,
|
||||||
item.position,
|
item.position,
|
||||||
item.asset.dimensions,
|
getScaledDimensions(item),
|
||||||
item.rotation,
|
item.rotation,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -331,7 +345,7 @@ export class SpatialGridManager {
|
|||||||
if (wallId && this.walls.has(wallId)) {
|
if (wallId && this.walls.has(wallId)) {
|
||||||
const wallLength = this.getWallLength(wallId)
|
const wallLength = this.getWallLength(wallId)
|
||||||
if (wallLength > 0) {
|
if (wallLength > 0) {
|
||||||
const [width, height] = item.asset.dimensions
|
const [width, height] = getScaledDimensions(item)
|
||||||
const halfW = width / wallLength / 2
|
const halfW = width / wallLength / 2
|
||||||
// Calculate t from local X position (position[0] is distance along wall)
|
// Calculate t from local X position (position[0] is distance along wall)
|
||||||
const t = item.position[0] / wallLength
|
const t = item.position[0] / wallLength
|
||||||
@@ -358,14 +372,14 @@ export class SpatialGridManager {
|
|||||||
// Insert into new ceiling grid
|
// Insert into new ceiling grid
|
||||||
const ceilingId = item.parentId
|
const ceilingId = item.parentId
|
||||||
if (ceilingId && this.ceilings.has(ceilingId)) {
|
if (ceilingId && this.ceilings.has(ceilingId)) {
|
||||||
this.getCeilingGrid(ceilingId).insert(item.id, item.position, item.asset.dimensions, item.rotation)
|
this.getCeilingGrid(ceilingId).insert(item.id, item.position, getScaledDimensions(item), item.rotation)
|
||||||
this.itemCeilingMap.set(item.id, ceilingId)
|
this.itemCeilingMap.set(item.id, ceilingId)
|
||||||
}
|
}
|
||||||
} else if (!item.asset.attachTo) {
|
} else if (!item.asset.attachTo) {
|
||||||
this.getFloorGrid(levelId).update(
|
this.getFloorGrid(levelId).update(
|
||||||
item.id,
|
item.id,
|
||||||
item.position,
|
item.position,
|
||||||
item.asset.dimensions,
|
getScaledDimensions(item),
|
||||||
item.rotation,
|
item.rotation,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { AnyNode, AnyNodeId, ItemNode, SlabNode, WallNode } from '../../schema'
|
import { getScaledDimensions, type AnyNode, type AnyNodeId, type ItemNode, type SlabNode, type WallNode } from '../../schema'
|
||||||
import useScene from '../../store/use-scene'
|
import useScene from '../../store/use-scene'
|
||||||
import { itemOverlapsPolygon, spatialGridManager, wallOverlapsPolygon } from './spatial-grid-manager'
|
import { itemOverlapsPolygon, spatialGridManager, wallOverlapsPolygon } from './spatial-grid-manager'
|
||||||
|
|
||||||
@@ -73,11 +73,16 @@ export function initSpatialGridSync() {
|
|||||||
if (
|
if (
|
||||||
!arraysEqual(node.position, prev.position) ||
|
!arraysEqual(node.position, prev.position) ||
|
||||||
!arraysEqual(node.rotation, prev.rotation) ||
|
!arraysEqual(node.rotation, prev.rotation) ||
|
||||||
|
!arraysEqual(node.scale, prev.scale) ||
|
||||||
node.parentId !== prev.parentId ||
|
node.parentId !== prev.parentId ||
|
||||||
node.side !== prev.side
|
node.side !== prev.side
|
||||||
) {
|
) {
|
||||||
const levelId = resolveLevelId(node, state.nodes)
|
const levelId = resolveLevelId(node, state.nodes)
|
||||||
spatialGridManager.handleNodeUpdated(node, levelId)
|
spatialGridManager.handleNodeUpdated(node, levelId)
|
||||||
|
// Scale changes affect footprint size — mark dirty so slab elevation recalculates
|
||||||
|
if (!arraysEqual(node.scale, prev.scale)) {
|
||||||
|
markDirty(node.id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else if (node.type === 'slab' && prev.type === 'slab') {
|
} else if (node.type === 'slab' && prev.type === 'slab') {
|
||||||
if (node.polygon !== prev.polygon || node.elevation !== prev.elevation) {
|
if (node.polygon !== prev.polygon || node.elevation !== prev.elevation) {
|
||||||
@@ -114,7 +119,7 @@ function markNodesOverlappingSlab(
|
|||||||
// Only floor items are affected by slabs
|
// Only floor items are affected by slabs
|
||||||
if (item.asset.attachTo) continue
|
if (item.asset.attachTo) continue
|
||||||
if (resolveLevelId(node, nodes) !== slabLevelId) continue
|
if (resolveLevelId(node, nodes) !== slabLevelId) continue
|
||||||
if (itemOverlapsPolygon(item.position, item.asset.dimensions, item.rotation, slab.polygon, 0.01)) {
|
if (itemOverlapsPolygon(item.position, getScaledDimensions(item), item.rotation, slab.polygon, 0.01)) {
|
||||||
markDirty(node.id)
|
markDirty(node.id)
|
||||||
}
|
}
|
||||||
} else if (node.type === 'wall') {
|
} else if (node.type === 'wall') {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ export { BaseNode, generateId, Material, nodeType, objectId } from './base'
|
|||||||
// Camera
|
// Camera
|
||||||
export { CameraSchema } from './camera'
|
export { CameraSchema } from './camera'
|
||||||
export type { AssetInput } from './nodes/item'
|
export type { AssetInput } from './nodes/item'
|
||||||
export { ItemNode } from './nodes/item'
|
export { getScaledDimensions, ItemNode } from './nodes/item'
|
||||||
export { LevelNode } from './nodes/level'
|
export { LevelNode } from './nodes/level'
|
||||||
// Nodes
|
// Nodes
|
||||||
export { SiteNode } from './nodes/site'
|
export { SiteNode } from './nodes/site'
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export const ItemNode = BaseNode.extend({
|
|||||||
type: nodeType('item'),
|
type: nodeType('item'),
|
||||||
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
|
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
|
||||||
rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
|
rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
|
||||||
|
scale: z.tuple([z.number(), z.number(), z.number()]).default([1, 1, 1]),
|
||||||
side: z.enum(['front', 'back']).optional(),
|
side: z.enum(['front', 'back']).optional(),
|
||||||
children: z.array(objectId('item')).default([]),
|
children: z.array(objectId('item')).default([]),
|
||||||
|
|
||||||
@@ -53,3 +54,13 @@ export const ItemNode = BaseNode.extend({
|
|||||||
`)
|
`)
|
||||||
|
|
||||||
export type ItemNode = z.infer<typeof ItemNode>
|
export type ItemNode = z.infer<typeof ItemNode>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the effective world-space dimensions of an item after applying its scale.
|
||||||
|
* Use this everywhere item.asset.dimensions is used for spatial calculations.
|
||||||
|
*/
|
||||||
|
export function getScaledDimensions(item: ItemNode): [number, number, number] {
|
||||||
|
const [w, h, d] = item.asset.dimensions
|
||||||
|
const [sx, sy, sz] = item.scale
|
||||||
|
return [w * sx, h * sy, d * sz]
|
||||||
|
}
|
||||||
|
|||||||
@@ -68,13 +68,21 @@ const useScene: UseSceneStore = create<SceneState>()(
|
|||||||
},
|
},
|
||||||
|
|
||||||
setScene: (nodes, rootNodeIds) => {
|
setScene: (nodes, rootNodeIds) => {
|
||||||
|
// Backward compat: add default scale to item nodes loaded from external sources
|
||||||
|
// (pascal_local_projects, Supabase) saved before scale was added to ItemNode
|
||||||
|
const patchedNodes = { ...nodes }
|
||||||
|
for (const [id, node] of Object.entries(patchedNodes)) {
|
||||||
|
if (node.type === 'item' && !('scale' in node)) {
|
||||||
|
patchedNodes[id as AnyNodeId] = { ...(node as object), scale: [1, 1, 1] } as AnyNode
|
||||||
|
}
|
||||||
|
}
|
||||||
set({
|
set({
|
||||||
nodes,
|
nodes: patchedNodes,
|
||||||
rootNodeIds,
|
rootNodeIds,
|
||||||
dirtyNodes: new Set<AnyNodeId>(),
|
dirtyNodes: new Set<AnyNodeId>(),
|
||||||
})
|
})
|
||||||
// Mark all nodes as dirty to trigger re-validation
|
// Mark all nodes as dirty to trigger re-validation
|
||||||
Object.values(nodes).forEach((node) => {
|
Object.values(patchedNodes).forEach((node) => {
|
||||||
get().markDirty(node.id)
|
get().markDirty(node.id)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
@@ -146,6 +154,7 @@ const useScene: UseSceneStore = create<SceneState>()(
|
|||||||
),
|
),
|
||||||
{
|
{
|
||||||
name: 'editor-storage',
|
name: 'editor-storage',
|
||||||
|
version: 1,
|
||||||
partialize: (state) => ({
|
partialize: (state) => ({
|
||||||
nodes: Object.fromEntries(
|
nodes: Object.fromEntries(
|
||||||
Object.entries(state.nodes).filter(([_, node]) => {
|
Object.entries(state.nodes).filter(([_, node]) => {
|
||||||
@@ -157,6 +166,19 @@ const useScene: UseSceneStore = create<SceneState>()(
|
|||||||
),
|
),
|
||||||
rootNodeIds: state.rootNodeIds,
|
rootNodeIds: state.rootNodeIds,
|
||||||
}),
|
}),
|
||||||
|
merge: (persistedState, currentState) => {
|
||||||
|
console.log('merge calling...', persistedState, currentState)
|
||||||
|
const persisted = persistedState as Partial<SceneState>
|
||||||
|
// Backward compat: add default scale to item nodes saved before scale was added
|
||||||
|
if (persisted.nodes) {
|
||||||
|
for (const [id, node] of Object.entries(persisted.nodes)) {
|
||||||
|
if (node.type === 'item' && !('scale' in node)) {
|
||||||
|
persisted.nodes[id as AnyNodeId] = { ...(node as object), scale: [1, 1, 1] } as AnyNode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ...currentState, ...persisted }
|
||||||
|
},
|
||||||
onRehydrateStorage: (state) => {
|
onRehydrateStorage: (state) => {
|
||||||
console.log('hydrating...')
|
console.log('hydrating...')
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type * as THREE from 'three'
|
|||||||
import { sceneRegistry } from '../../hooks/scene-registry/scene-registry'
|
import { sceneRegistry } from '../../hooks/scene-registry/scene-registry'
|
||||||
import { spatialGridManager } from '../../hooks/spatial-grid/spatial-grid-manager'
|
import { spatialGridManager } from '../../hooks/spatial-grid/spatial-grid-manager'
|
||||||
import { resolveLevelId } from '../../hooks/spatial-grid/spatial-grid-sync'
|
import { resolveLevelId } from '../../hooks/spatial-grid/spatial-grid-sync'
|
||||||
import type { AnyNodeId, ItemNode, WallNode } from '../../schema'
|
import { getScaledDimensions, type AnyNodeId, type ItemNode, type WallNode } from '../../schema'
|
||||||
import useScene from '../../store/use-scene'
|
import useScene from '../../store/use-scene'
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -43,7 +43,7 @@ export const ItemSystem = () => {
|
|||||||
const slabElevation = spatialGridManager.getSlabElevationForItem(
|
const slabElevation = spatialGridManager.getSlabElevationForItem(
|
||||||
levelId,
|
levelId,
|
||||||
item.position,
|
item.position,
|
||||||
item.asset.dimensions,
|
getScaledDimensions(item),
|
||||||
item.rotation,
|
item.rotation,
|
||||||
)
|
)
|
||||||
mesh.position.y = slabElevation + item.position[1]
|
mesh.position.y = slabElevation + item.position[1]
|
||||||
@@ -51,7 +51,7 @@ export const ItemSystem = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
clearDirty(id as AnyNodeId)
|
clearDirty(id as AnyNodeId)
|
||||||
})
|
}, 2)
|
||||||
})
|
})
|
||||||
|
|
||||||
return null
|
return null
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export const SlabSystem = () => {
|
|||||||
}
|
}
|
||||||
// If mesh not found, keep it dirty for next frame
|
// If mesh not found, keep it dirty for next frame
|
||||||
})
|
})
|
||||||
})
|
}, 1)
|
||||||
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useFrame } from '@react-three/fiber'
|
import { useFrame } from '@react-three/fiber'
|
||||||
import * as THREE from 'three'
|
import * as THREE from 'three'
|
||||||
import { computeBoundsTree } from 'three-mesh-bvh'
|
|
||||||
import { Brush, Evaluator, SUBTRACTION } from 'three-bvh-csg'
|
import { Brush, Evaluator, SUBTRACTION } from 'three-bvh-csg'
|
||||||
|
import { computeBoundsTree } from 'three-mesh-bvh'
|
||||||
import { sceneRegistry } from '../../hooks/scene-registry/scene-registry'
|
import { sceneRegistry } from '../../hooks/scene-registry/scene-registry'
|
||||||
import { spatialGridManager } from '../../hooks/spatial-grid/spatial-grid-manager'
|
import { spatialGridManager } from '../../hooks/spatial-grid/spatial-grid-manager'
|
||||||
import { resolveLevelId } from '../../hooks/spatial-grid/spatial-grid-sync'
|
import { resolveLevelId } from '../../hooks/spatial-grid/spatial-grid-sync'
|
||||||
@@ -22,7 +22,7 @@ const csgEvaluator = new Evaluator()
|
|||||||
// WALL SYSTEM
|
// WALL SYSTEM
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
let useFrameNb = 0;
|
let useFrameNb = 0
|
||||||
export const WallSystem = () => {
|
export const WallSystem = () => {
|
||||||
const dirtyNodes = useScene((state) => state.dirtyNodes)
|
const dirtyNodes = useScene((state) => state.dirtyNodes)
|
||||||
const clearDirty = useScene((state) => state.clearDirty)
|
const clearDirty = useScene((state) => state.clearDirty)
|
||||||
@@ -35,7 +35,7 @@ export const WallSystem = () => {
|
|||||||
// Collect dirty walls and their levels
|
// Collect dirty walls and their levels
|
||||||
const dirtyWallsByLevel = new Map<string, Set<string>>()
|
const dirtyWallsByLevel = new Map<string, Set<string>>()
|
||||||
|
|
||||||
useFrameNb += 1;
|
useFrameNb += 1
|
||||||
dirtyNodes.forEach((id) => {
|
dirtyNodes.forEach((id) => {
|
||||||
const node = nodes[id]
|
const node = nodes[id]
|
||||||
if (!node || node.type !== 'wall') return
|
if (!node || node.type !== 'wall') return
|
||||||
@@ -75,7 +75,7 @@ export const WallSystem = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
}, 4)
|
||||||
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -108,7 +108,6 @@ function updateWallGeometry(wallId: string, miterData: WallMiterData) {
|
|||||||
const node = nodes[wallId as WallNode['id']]
|
const node = nodes[wallId as WallNode['id']]
|
||||||
if (!node || node.type !== 'wall') return
|
if (!node || node.type !== 'wall') return
|
||||||
|
|
||||||
|
|
||||||
const mesh = sceneRegistry.nodes.get(wallId) as THREE.Mesh
|
const mesh = sceneRegistry.nodes.get(wallId) as THREE.Mesh
|
||||||
if (!mesh) return
|
if (!mesh) return
|
||||||
|
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ export const WindowSystem = () => {
|
|||||||
useScene.getState().dirtyNodes.add((node as WindowNode).parentId as AnyNodeId)
|
useScene.getState().dirtyNodes.add((node as WindowNode).parentId as AnyNodeId)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
}, 3)
|
||||||
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,6 +73,9 @@ const PreviewModel = ({ node }: { node: ItemNode }) => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const multiplyScales = (a: [number, number, number], b: [number, number, number]): [number, number, number] =>
|
||||||
|
[a[0] * b[0], a[1] * b[1], a[2] * b[2]]
|
||||||
|
|
||||||
const ModelRenderer = ({ node }: { node: ItemNode }) => {
|
const ModelRenderer = ({ node }: { node: ItemNode }) => {
|
||||||
const { scene, nodes } = useGLTF(resolveCdnUrl(node.asset.src) || '')
|
const { scene, nodes } = useGLTF(resolveCdnUrl(node.asset.src) || '')
|
||||||
|
|
||||||
@@ -115,7 +118,7 @@ const ModelRenderer = ({ node }: { node: ItemNode }) => {
|
|||||||
return (
|
return (
|
||||||
<Clone
|
<Clone
|
||||||
object={scene}
|
object={scene}
|
||||||
scale={node.asset.scale}
|
scale={multiplyScales(node.asset.scale, node.scale)}
|
||||||
position={node.asset.offset}
|
position={node.asset.offset}
|
||||||
rotation={node.asset.rotation}
|
rotation={node.asset.rotation}
|
||||||
{...handlers}
|
{...handlers}
|
||||||
|
|||||||
@@ -33,7 +33,10 @@ function getLevelHeight(
|
|||||||
} else if (child.type === 'wall') {
|
} else if (child.type === 'wall') {
|
||||||
// Wall mesh is pushed up to slabElevation by WallSystem.
|
// Wall mesh is pushed up to slabElevation by WallSystem.
|
||||||
// mesh.position.y + wall.height gives the actual top Y in level-local space.
|
// mesh.position.y + wall.height gives the actual top Y in level-local space.
|
||||||
const meshY = sceneRegistry.nodes.get(childId as any)?.position.y ?? 0
|
let meshY = sceneRegistry.nodes.get(childId as any)?.position.y ?? 0
|
||||||
|
if (meshY < 0) {
|
||||||
|
meshY = 0 // Guard against invalid negative Y which could cause incorrect height calculation (e.g. from sunken slabs)
|
||||||
|
}
|
||||||
const top = meshY + ((child as WallNode).height ?? DEFAULT_LEVEL_HEIGHT)
|
const top = meshY + ((child as WallNode).height ?? DEFAULT_LEVEL_HEIGHT)
|
||||||
if (top > maxTop) maxTop = top
|
if (top > maxTop) maxTop = top
|
||||||
}
|
}
|
||||||
@@ -78,11 +81,11 @@ export const LevelSystem = () => {
|
|||||||
const explodedExtra = levelMode === 'exploded' ? index * EXPLODED_GAP : 0
|
const explodedExtra = levelMode === 'exploded' ? index * EXPLODED_GAP : 0
|
||||||
const targetY = baseY + explodedExtra
|
const targetY = baseY + explodedExtra
|
||||||
|
|
||||||
obj.position.y = lerp(obj.position.y, targetY, delta * 3)
|
obj.position.y = lerp(obj.position.y, targetY, delta * 12) // Smoothly animate to new Y position
|
||||||
obj.visible = levelMode !== 'solo' || level?.id === selectedLevel || !selectedLevel
|
obj.visible = levelMode !== 'solo' || level?.id === selectedLevel || !selectedLevel
|
||||||
|
|
||||||
cumulativeY += getLevelHeight(levelId, nodes)
|
cumulativeY += getLevelHeight(levelId, nodes)
|
||||||
}
|
}
|
||||||
})
|
}, 5) // Using a lower priority so it runs after transforms from other systems have settled
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user