item surfaces
This commit is contained in:
@@ -1,11 +1,16 @@
|
|||||||
import type {
|
import type {
|
||||||
AnyNode,
|
AnyNode,
|
||||||
|
AnyNodeId,
|
||||||
CeilingEvent,
|
CeilingEvent,
|
||||||
CeilingNode,
|
CeilingNode,
|
||||||
GridEvent,
|
GridEvent,
|
||||||
|
ItemEvent,
|
||||||
|
ItemNode,
|
||||||
WallEvent,
|
WallEvent,
|
||||||
WallNode,
|
WallNode,
|
||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
|
import { sceneRegistry, useScene } from '@pascal-app/core'
|
||||||
|
import { Vector3 } from 'three'
|
||||||
import type {
|
import type {
|
||||||
CommitResult,
|
CommitResult,
|
||||||
LevelResolver,
|
LevelResolver,
|
||||||
@@ -373,6 +378,105 @@ export const ceilingStrategy = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// ITEM SURFACE STRATEGY
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const itemSurfaceStrategy = {
|
||||||
|
/**
|
||||||
|
* Handle item:enter — transition from floor to an item surface.
|
||||||
|
* Returns null if: item has no surface, our item doesn't fit, or it's the draft itself.
|
||||||
|
*/
|
||||||
|
enter(ctx: PlacementContext, event: ItemEvent): TransitionResult | null {
|
||||||
|
// Only floor items can be placed on surfaces
|
||||||
|
if (ctx.asset.attachTo) return null
|
||||||
|
|
||||||
|
const surfaceItem = event.node as ItemNode
|
||||||
|
// Don't surface-place on the draft itself
|
||||||
|
if (surfaceItem.id === ctx.draftItem?.id) return null
|
||||||
|
// Surface item must declare a surface
|
||||||
|
if (!surfaceItem.asset.surface) return null
|
||||||
|
|
||||||
|
// Size check: our footprint must fit on surface item's footprint
|
||||||
|
const ourDims = ctx.asset.dimensions ?? DEFAULT_DIMENSIONS
|
||||||
|
const surfDims = surfaceItem.asset.dimensions
|
||||||
|
if (ourDims[0] > surfDims[0] || ourDims[2] > surfDims[2]) return null
|
||||||
|
|
||||||
|
const surfaceMesh = sceneRegistry.nodes.get(surfaceItem.id)
|
||||||
|
if (!surfaceMesh) return null
|
||||||
|
|
||||||
|
const worldPos = new Vector3(event.position[0], event.position[1], event.position[2])
|
||||||
|
const localPos = surfaceMesh.worldToLocal(worldPos)
|
||||||
|
|
||||||
|
const x = snapToGrid(localPos.x, ourDims[0])
|
||||||
|
const z = snapToGrid(localPos.z, ourDims[2])
|
||||||
|
const y = surfaceItem.asset.surface.height
|
||||||
|
|
||||||
|
const worldSnapped = surfaceMesh.localToWorld(new Vector3(x, y, z))
|
||||||
|
|
||||||
|
return {
|
||||||
|
stateUpdate: { surface: 'item-surface', surfaceItemId: surfaceItem.id },
|
||||||
|
nodeUpdate: { position: [x, y, z], parentId: surfaceItem.id },
|
||||||
|
cursorRotationY: 0,
|
||||||
|
gridPosition: [x, y, z],
|
||||||
|
cursorPosition: [worldSnapped.x, worldSnapped.y, worldSnapped.z],
|
||||||
|
stopPropagation: true,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle item:move — update position while on an item surface.
|
||||||
|
*/
|
||||||
|
move(ctx: PlacementContext, event: ItemEvent): PlacementResult | null {
|
||||||
|
if (ctx.state.surface !== 'item-surface') return null
|
||||||
|
if (!ctx.state.surfaceItemId || !ctx.draftItem) return null
|
||||||
|
|
||||||
|
const nodes = useScene.getState().nodes
|
||||||
|
const surfaceItem = nodes[ctx.state.surfaceItemId as AnyNodeId] as ItemNode | undefined
|
||||||
|
if (!surfaceItem?.asset.surface) return null
|
||||||
|
|
||||||
|
const surfaceMesh = sceneRegistry.nodes.get(ctx.state.surfaceItemId)
|
||||||
|
if (!surfaceMesh) return null
|
||||||
|
|
||||||
|
const ourDims = ctx.asset.dimensions ?? DEFAULT_DIMENSIONS
|
||||||
|
const worldPos = new Vector3(event.position[0], event.position[1], event.position[2])
|
||||||
|
const localPos = surfaceMesh.worldToLocal(worldPos)
|
||||||
|
|
||||||
|
const x = snapToGrid(localPos.x, ourDims[0])
|
||||||
|
const z = snapToGrid(localPos.z, ourDims[2])
|
||||||
|
const y = surfaceItem.asset.surface.height
|
||||||
|
|
||||||
|
const worldSnapped = surfaceMesh.localToWorld(new Vector3(x, y, z))
|
||||||
|
|
||||||
|
return {
|
||||||
|
gridPosition: [x, y, z],
|
||||||
|
cursorPosition: [worldSnapped.x, worldSnapped.y, worldSnapped.z],
|
||||||
|
cursorRotationY: 0,
|
||||||
|
nodeUpdate: { position: [x, y, z] },
|
||||||
|
stopPropagation: true,
|
||||||
|
dirtyNodeId: null,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle item:click — commit placement on item surface.
|
||||||
|
*/
|
||||||
|
click(ctx: PlacementContext, _event: ItemEvent): CommitResult | null {
|
||||||
|
if (ctx.state.surface !== 'item-surface') return null
|
||||||
|
if (!ctx.draftItem || !ctx.state.surfaceItemId) return null
|
||||||
|
|
||||||
|
return {
|
||||||
|
nodeUpdate: {
|
||||||
|
position: [ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z],
|
||||||
|
parentId: ctx.state.surfaceItemId,
|
||||||
|
metadata: stripTransient(ctx.draftItem.metadata),
|
||||||
|
},
|
||||||
|
stopPropagation: true,
|
||||||
|
dirtyNodeId: null,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// VALIDATION
|
// VALIDATION
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -384,6 +488,11 @@ export const ceilingStrategy = {
|
|||||||
export function checkCanPlace(ctx: PlacementContext, validators: SpatialValidators): boolean {
|
export function checkCanPlace(ctx: PlacementContext, validators: SpatialValidators): boolean {
|
||||||
if (!ctx.levelId || !ctx.draftItem) return false
|
if (!ctx.levelId || !ctx.draftItem) return false
|
||||||
|
|
||||||
|
// Item surface: valid if we entered (size check was in enter)
|
||||||
|
if (ctx.state.surface === 'item-surface') {
|
||||||
|
return ctx.state.surfaceItemId !== null
|
||||||
|
}
|
||||||
|
|
||||||
const attachTo = ctx.draftItem.asset.attachTo
|
const attachTo = ctx.draftItem.asset.attachTo
|
||||||
|
|
||||||
if (attachTo === 'ceiling') {
|
if (attachTo === 'ceiling') {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import type { Vector3 } from 'three'
|
|||||||
// PLACEMENT STATE
|
// PLACEMENT STATE
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
export type SurfaceType = 'floor' | 'wall' | 'ceiling'
|
export type SurfaceType = 'floor' | 'wall' | 'ceiling' | 'item-surface'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tracks which surface the draft item is currently on.
|
* Tracks which surface the draft item is currently on.
|
||||||
@@ -15,6 +15,7 @@ export interface PlacementState {
|
|||||||
surface: SurfaceType
|
surface: SurfaceType
|
||||||
wallId: string | null
|
wallId: string | null
|
||||||
ceilingId: string | null
|
ceilingId: string | null
|
||||||
|
surfaceItemId: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
type CeilingEvent,
|
type CeilingEvent,
|
||||||
emitter,
|
emitter,
|
||||||
type GridEvent,
|
type GridEvent,
|
||||||
|
type ItemEvent,
|
||||||
resolveLevelId,
|
resolveLevelId,
|
||||||
sceneRegistry,
|
sceneRegistry,
|
||||||
spatialGridManager,
|
spatialGridManager,
|
||||||
@@ -29,7 +30,7 @@ import {
|
|||||||
import { distance, smoothstep, uv, vec2 } from 'three/tsl'
|
import { distance, smoothstep, uv, vec2 } from 'three/tsl'
|
||||||
import { LineBasicNodeMaterial, MeshBasicNodeMaterial } from 'three/webgpu'
|
import { LineBasicNodeMaterial, MeshBasicNodeMaterial } from 'three/webgpu'
|
||||||
import { sfxEmitter } from '@/lib/sfx-bus'
|
import { sfxEmitter } from '@/lib/sfx-bus'
|
||||||
import { ceilingStrategy, checkCanPlace, floorStrategy, wallStrategy } from './placement-strategies'
|
import { ceilingStrategy, checkCanPlace, floorStrategy, itemSurfaceStrategy, wallStrategy } from './placement-strategies'
|
||||||
import type { PlacementState, TransitionResult } from './placement-types'
|
import type { PlacementState, TransitionResult } from './placement-types'
|
||||||
import type { DraftNodeHandle } from './use-draft-node'
|
import type { DraftNodeHandle } from './use-draft-node'
|
||||||
|
|
||||||
@@ -93,6 +94,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
surface: 'floor',
|
surface: 'floor',
|
||||||
wallId: null,
|
wallId: null,
|
||||||
ceilingId: null,
|
ceilingId: null,
|
||||||
|
surfaceItemId: null,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Helpers ----
|
// ---- Helpers ----
|
||||||
@@ -367,6 +369,114 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Item Surface Handlers ----
|
||||||
|
|
||||||
|
const onItemEnter = (event: ItemEvent) => {
|
||||||
|
if (event.node.id === draftNode.current?.id) return
|
||||||
|
const result = itemSurfaceStrategy.enter(getContext(), event)
|
||||||
|
if (!result) return
|
||||||
|
|
||||||
|
event.stopPropagation()
|
||||||
|
applyTransition(result)
|
||||||
|
|
||||||
|
if (!draftNode.current) {
|
||||||
|
ensureDraft(result)
|
||||||
|
} else if (result.nodeUpdate.parentId) {
|
||||||
|
// Existing draft (move mode): reparent to surface item
|
||||||
|
useScene.getState().updateNode(draftNode.current.id, result.nodeUpdate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onItemMove = (event: ItemEvent) => {
|
||||||
|
if (event.node.id === draftNode.current?.id) return
|
||||||
|
const ctx = getContext()
|
||||||
|
|
||||||
|
if (ctx.state.surface !== 'item-surface') {
|
||||||
|
// Try entering surface mode
|
||||||
|
const enterResult = itemSurfaceStrategy.enter(ctx, event)
|
||||||
|
if (!enterResult) return
|
||||||
|
|
||||||
|
event.stopPropagation()
|
||||||
|
applyTransition(enterResult)
|
||||||
|
if (draftNode.current && enterResult.nodeUpdate.parentId) {
|
||||||
|
useScene.getState().updateNode(draftNode.current.id, enterResult.nodeUpdate)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!draftNode.current) {
|
||||||
|
const enterResult = itemSurfaceStrategy.enter(getContext(), event)
|
||||||
|
if (!enterResult) return
|
||||||
|
event.stopPropagation()
|
||||||
|
ensureDraft(enterResult)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = itemSurfaceStrategy.move(ctx, event)
|
||||||
|
if (!result) return
|
||||||
|
|
||||||
|
event.stopPropagation()
|
||||||
|
|
||||||
|
gridPosition.current.set(...result.gridPosition)
|
||||||
|
cursorGroupRef.current.position.set(...result.cursorPosition)
|
||||||
|
cursorGroupRef.current.rotation.y = result.cursorRotationY
|
||||||
|
|
||||||
|
const draft = draftNode.current
|
||||||
|
if (draft) {
|
||||||
|
draft.position = result.gridPosition
|
||||||
|
const mesh = sceneRegistry.nodes.get(draft.id)
|
||||||
|
if (mesh) mesh.position.set(...result.gridPosition)
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidate()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onItemLeave = (event: ItemEvent) => {
|
||||||
|
if (event.node.id === draftNode.current?.id) return
|
||||||
|
if (placementState.current.surface !== 'item-surface') return
|
||||||
|
|
||||||
|
event.stopPropagation()
|
||||||
|
|
||||||
|
// Transition back to floor using event world position
|
||||||
|
const wx = Math.round(event.position[0] * 2) / 2
|
||||||
|
const wz = Math.round(event.position[2] * 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, event.position[1], 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) => {
|
||||||
|
if (event.node.id === draftNode.current?.id) return
|
||||||
|
const result = itemSurfaceStrategy.click(getContext(), event)
|
||||||
|
if (!result) return
|
||||||
|
|
||||||
|
event.stopPropagation()
|
||||||
|
draftNode.commit(result.nodeUpdate)
|
||||||
|
|
||||||
|
if (configRef.current.onCommitted()) {
|
||||||
|
// Try to set up next draft on the same surface
|
||||||
|
const enterResult = itemSurfaceStrategy.enter(getContext(), event)
|
||||||
|
if (enterResult) {
|
||||||
|
applyTransition(enterResult)
|
||||||
|
} else {
|
||||||
|
revalidate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Ceiling Handlers ----
|
// ---- Ceiling Handlers ----
|
||||||
|
|
||||||
const onCeilingEnter = (event: CeilingEvent) => {
|
const onCeilingEnter = (event: CeilingEvent) => {
|
||||||
@@ -546,6 +656,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
|
|
||||||
emitter.on('grid:move', onGridMove)
|
emitter.on('grid:move', onGridMove)
|
||||||
emitter.on('grid:click', onGridClick)
|
emitter.on('grid:click', onGridClick)
|
||||||
|
emitter.on('item:enter', onItemEnter)
|
||||||
|
emitter.on('item:move', onItemMove)
|
||||||
|
emitter.on('item:leave', onItemLeave)
|
||||||
|
emitter.on('item:click', onItemClick)
|
||||||
emitter.on('wall:enter', onWallEnter)
|
emitter.on('wall:enter', onWallEnter)
|
||||||
emitter.on('wall:move', onWallMove)
|
emitter.on('wall:move', onWallMove)
|
||||||
emitter.on('wall:click', onWallClick)
|
emitter.on('wall:click', onWallClick)
|
||||||
@@ -560,6 +674,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
useScene.temporal.getState().resume()
|
useScene.temporal.getState().resume()
|
||||||
emitter.off('grid:move', onGridMove)
|
emitter.off('grid:move', onGridMove)
|
||||||
emitter.off('grid:click', onGridClick)
|
emitter.off('grid:click', onGridClick)
|
||||||
|
emitter.off('item:enter', onItemEnter)
|
||||||
|
emitter.off('item:move', onItemMove)
|
||||||
|
emitter.off('item:leave', onItemLeave)
|
||||||
|
emitter.off('item:click', onItemClick)
|
||||||
emitter.off('wall:enter', onWallEnter)
|
emitter.off('wall:enter', onWallEnter)
|
||||||
emitter.off('wall:move', onWallMove)
|
emitter.off('wall:move', onWallMove)
|
||||||
emitter.off('wall:click', onWallClick)
|
emitter.off('wall:click', onWallClick)
|
||||||
|
|||||||
@@ -435,6 +435,9 @@ export const CATALOG_ITEMS: AssetInput[] = [
|
|||||||
offset: [0, 0, 0],
|
offset: [0, 0, 0],
|
||||||
rotation: [0, 0, 0],
|
rotation: [0, 0, 0],
|
||||||
dimensions: [2, 0.8, 1],
|
dimensions: [2, 0.8, 1],
|
||||||
|
surface: {
|
||||||
|
height: 0.75
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
@@ -448,6 +451,9 @@ export const CATALOG_ITEMS: AssetInput[] = [
|
|||||||
offset: [0, 0, 0],
|
offset: [0, 0, 0],
|
||||||
rotation: [0, 0, 0],
|
rotation: [0, 0, 0],
|
||||||
dimensions: [2, 1.1, 1],
|
dimensions: [2, 1.1, 1],
|
||||||
|
surface: {
|
||||||
|
height: 1.1
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
@@ -1176,6 +1182,9 @@ export const CATALOG_ITEMS: AssetInput[] = [
|
|||||||
rotation: [0, 0, 0],
|
rotation: [0, 0, 0],
|
||||||
dimensions: [1, 0.5, 0.7],
|
dimensions: [1, 0.5, 0.7],
|
||||||
attachTo: "wall-side",
|
attachTo: "wall-side",
|
||||||
|
surface: {
|
||||||
|
height: 0.12
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
@@ -1255,6 +1264,9 @@ export const CATALOG_ITEMS: AssetInput[] = [
|
|||||||
offset: [0, 0, 0],
|
offset: [0, 0, 0],
|
||||||
rotation: [0, 0, 0],
|
rotation: [0, 0, 0],
|
||||||
dimensions: [1.5, 0.8, 1],
|
dimensions: [1.5, 0.8, 1],
|
||||||
|
surface: {
|
||||||
|
height: 0.8
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
@@ -1385,6 +1397,9 @@ export const CATALOG_ITEMS: AssetInput[] = [
|
|||||||
offset: [0, 0, -0.01],
|
offset: [0, 0, -0.01],
|
||||||
rotation: [0, 0, 0],
|
rotation: [0, 0, 0],
|
||||||
dimensions: [0.5, 0.5, 0.5],
|
dimensions: [0.5, 0.5, 0.5],
|
||||||
|
surface: {
|
||||||
|
height: 0.5
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
@@ -1398,6 +1413,9 @@ export const CATALOG_ITEMS: AssetInput[] = [
|
|||||||
offset: [0, 0, 0],
|
offset: [0, 0, 0],
|
||||||
rotation: [0, 0, 0],
|
rotation: [0, 0, 0],
|
||||||
dimensions: [2, 0.4, 1.5],
|
dimensions: [2, 0.4, 1.5],
|
||||||
|
surface: {
|
||||||
|
height: 0.3
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
@@ -1411,6 +1429,9 @@ export const CATALOG_ITEMS: AssetInput[] = [
|
|||||||
offset: [0, 0, 0],
|
offset: [0, 0, 0],
|
||||||
rotation: [0, 0, 0],
|
rotation: [0, 0, 0],
|
||||||
dimensions: [2, 0.8, 1],
|
dimensions: [2, 0.8, 1],
|
||||||
|
surface: {
|
||||||
|
height: 0.75
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
@@ -1424,5 +1445,8 @@ export const CATALOG_ITEMS: AssetInput[] = [
|
|||||||
offset: [0, 0, -0.01],
|
offset: [0, 0, -0.01],
|
||||||
rotation: [0, 0, 0],
|
rotation: [0, 0, 0],
|
||||||
dimensions: [2.5, 0.8, 1],
|
dimensions: [2.5, 0.8, 1],
|
||||||
|
surface: {
|
||||||
|
height: 0.8
|
||||||
|
}
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useViewer } from "@pascal-app/viewer";
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { RenamePopover } from "./rename-popover";
|
import { RenamePopover } from "./rename-popover";
|
||||||
import { TreeNodeWrapper } from "./tree-node";
|
import { TreeNode, TreeNodeWrapper } from "./tree-node";
|
||||||
import { TreeNodeActions } from "./tree-node-actions";
|
import { TreeNodeActions } from "./tree-node-actions";
|
||||||
|
|
||||||
const CATEGORY_ICONS: Record<string, string> = {
|
const CATEGORY_ICONS: Record<string, string> = {
|
||||||
@@ -23,6 +23,7 @@ interface ItemTreeNodeProps {
|
|||||||
|
|
||||||
export function ItemTreeNode({ node, depth }: ItemTreeNodeProps) {
|
export function ItemTreeNode({ node, depth }: ItemTreeNodeProps) {
|
||||||
const [renameOpen, setRenameOpen] = useState(false);
|
const [renameOpen, setRenameOpen] = useState(false);
|
||||||
|
const [expanded, setExpanded] = useState(true);
|
||||||
const iconSrc = CATEGORY_ICONS[node.asset.category] || "/icons/couch.png";
|
const iconSrc = CATEGORY_ICONS[node.asset.category] || "/icons/couch.png";
|
||||||
const isSelected = useViewer((state) => state.selection.selectedIds.includes(node.id));
|
const isSelected = useViewer((state) => state.selection.selectedIds.includes(node.id));
|
||||||
const isHovered = useViewer((state) => state.hoveredId === node.id);
|
const isHovered = useViewer((state) => state.hoveredId === node.id);
|
||||||
@@ -46,6 +47,7 @@ export function ItemTreeNode({ node, depth }: ItemTreeNodeProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const defaultName = node.asset.name || "Item";
|
const defaultName = node.asset.name || "Item";
|
||||||
|
const hasChildren = node.children && node.children.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<RenamePopover
|
<RenamePopover
|
||||||
@@ -58,9 +60,9 @@ export function ItemTreeNode({ node, depth }: ItemTreeNodeProps) {
|
|||||||
icon={<Image src={iconSrc} alt="" width={14} height={14} className="object-contain" />}
|
icon={<Image src={iconSrc} alt="" width={14} height={14} className="object-contain" />}
|
||||||
label={node.name || defaultName}
|
label={node.name || defaultName}
|
||||||
depth={depth}
|
depth={depth}
|
||||||
hasChildren={false}
|
hasChildren={hasChildren}
|
||||||
expanded={false}
|
expanded={expanded}
|
||||||
onToggle={() => {}}
|
onToggle={() => setExpanded(!expanded)}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
onDoubleClick={handleDoubleClick}
|
onDoubleClick={handleDoubleClick}
|
||||||
onMouseEnter={handleMouseEnter}
|
onMouseEnter={handleMouseEnter}
|
||||||
@@ -69,7 +71,11 @@ export function ItemTreeNode({ node, depth }: ItemTreeNodeProps) {
|
|||||||
isHovered={isHovered}
|
isHovered={isHovered}
|
||||||
isVisible={node.visible !== false}
|
isVisible={node.visible !== false}
|
||||||
actions={<TreeNodeActions node={node} />}
|
actions={<TreeNodeActions node={node} />}
|
||||||
/>
|
>
|
||||||
|
{hasChildren && node.children.map((childId) => (
|
||||||
|
<TreeNode key={childId} nodeId={childId} depth={depth + 1} />
|
||||||
|
))}
|
||||||
|
</TreeNodeWrapper>
|
||||||
</RenamePopover>
|
</RenamePopover>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,11 @@ const assetSchema = z.object({
|
|||||||
offset: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
|
offset: 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]),
|
scale: z.tuple([z.number(), z.number(), z.number()]).default([1, 1, 1]),
|
||||||
|
surface: z
|
||||||
|
.object({
|
||||||
|
height: z.number(), // where things rest
|
||||||
|
})
|
||||||
|
.optional(), // undefined = can't place things on it
|
||||||
})
|
})
|
||||||
|
|
||||||
export type AssetInput = z.input<typeof assetSchema>
|
export type AssetInput = z.input<typeof assetSchema>
|
||||||
@@ -26,6 +31,7 @@ export const ItemNode = BaseNode.extend({
|
|||||||
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]),
|
||||||
side: z.enum(['front', 'back']).optional(),
|
side: z.enum(['front', 'back']).optional(),
|
||||||
|
children: z.array(z.string()).default([]),
|
||||||
|
|
||||||
// Wall attachment properties (only used when asset.attachTo is "wall" or "wall-side")
|
// Wall attachment properties (only used when asset.attachTo is "wall" or "wall-side")
|
||||||
wallId: z.string().optional(),
|
wallId: z.string().optional(),
|
||||||
|
|||||||
@@ -35,15 +35,19 @@ export const ItemSystem = () => {
|
|||||||
mesh.position.z = (wallThickness / 2) * side;
|
mesh.position.z = (wallThickness / 2) * side;
|
||||||
}
|
}
|
||||||
} else if (!item.asset.attachTo) {
|
} else if (!item.asset.attachTo) {
|
||||||
// Floor item: elevate by slab height (using full footprint overlap)
|
// If parented to another item (surface placement), R3F handles positioning via the hierarchy
|
||||||
const levelId = resolveLevelId(item, nodes)
|
const parentNode = item.parentId ? nodes[item.parentId as AnyNodeId] : undefined
|
||||||
const slabElevation = spatialGridManager.getSlabElevationForItem(
|
if (parentNode?.type !== 'item') {
|
||||||
levelId,
|
// Floor item: elevate by slab height (using full footprint overlap)
|
||||||
item.position,
|
const levelId = resolveLevelId(item, nodes)
|
||||||
item.asset.dimensions,
|
const slabElevation = spatialGridManager.getSlabElevationForItem(
|
||||||
item.rotation,
|
levelId,
|
||||||
)
|
item.position,
|
||||||
mesh.position.y = slabElevation + item.position[1]
|
item.asset.dimensions,
|
||||||
|
item.rotation,
|
||||||
|
)
|
||||||
|
mesh.position.y = slabElevation + item.position[1]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
clearDirty(id as AnyNodeId)
|
clearDirty(id as AnyNodeId)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { positionLocal, smoothstep, time } from 'three/tsl'
|
|||||||
import { DoubleSide, MeshStandardNodeMaterial } from 'three/webgpu'
|
import { DoubleSide, MeshStandardNodeMaterial } from 'three/webgpu'
|
||||||
import { useNodeEvents } from '../../../hooks/use-node-events'
|
import { useNodeEvents } from '../../../hooks/use-node-events'
|
||||||
import { resolveCdnUrl } from '../../../lib/asset-url'
|
import { resolveCdnUrl } from '../../../lib/asset-url'
|
||||||
|
import { NodeRenderer } from '../node-renderer'
|
||||||
|
|
||||||
// Shared materials to avoid creating new instances for every mesh
|
// Shared materials to avoid creating new instances for every mesh
|
||||||
const defaultMaterial = new MeshStandardNodeMaterial({
|
const defaultMaterial = new MeshStandardNodeMaterial({
|
||||||
@@ -43,6 +44,9 @@ export const ItemRenderer = ({ node }: { node: ItemNode }) => {
|
|||||||
<Suspense fallback={<PreviewModel node={node} />}>
|
<Suspense fallback={<PreviewModel node={node} />}>
|
||||||
<ModelRenderer node={node} />
|
<ModelRenderer node={node} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
{node.children?.map((childId) => (
|
||||||
|
<NodeRenderer key={childId} nodeId={childId as ItemNode['id']} />
|
||||||
|
))}
|
||||||
</group>
|
</group>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user