diff --git a/apps/editor/components/tools/item/placement-strategies.ts b/apps/editor/components/tools/item/placement-strategies.ts index 08b782ef..0747b33d 100644 --- a/apps/editor/components/tools/item/placement-strategies.ts +++ b/apps/editor/components/tools/item/placement-strategies.ts @@ -9,7 +9,7 @@ import type { WallEvent, WallNode, } from '@pascal-app/core' -import { sceneRegistry, useScene } from '@pascal-app/core' +import { getScaledDimensions, sceneRegistry, useScene } from '@pascal-app/core' import { Vector3 } from 'three' import type { CommitResult, @@ -43,7 +43,7 @@ export const floorStrategy = { move(ctx: PlacementContext, event: GridEvent): PlacementResult | 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 x = snapToGrid(event.position[0], dimX) const z = snapToGrid(event.position[2], dimZ) @@ -70,7 +70,7 @@ export const floorStrategy = { const valid = validators.canPlaceOnFloor( ctx.levelId, pos, - ctx.draftItem.asset.dimensions, + getScaledDimensions(ctx.draftItem), [0, 0, 0], [ctx.draftItem.id], ).valid @@ -128,7 +128,7 @@ export const wallStrategy = { event.node.id, x, y, - ctx.asset.dimensions ?? DEFAULT_DIMENSIONS, + ctx.draftItem ? getScaledDimensions(ctx.draftItem) : (ctx.asset.dimensions ?? DEFAULT_DIMENSIONS), attachTo, side, [], @@ -179,7 +179,7 @@ export const wallStrategy = { event.node.id, snappedX, snappedY, - ctx.draftItem.asset.dimensions, + getScaledDimensions(ctx.draftItem), ctx.draftItem.asset.attachTo as 'wall' | 'wall-side', side, [ctx.draftItem.id], @@ -219,7 +219,7 @@ export const wallStrategy = { ctx.state.wallId as WallNode['id'], ctx.gridPosition.x, ctx.gridPosition.y, - ctx.draftItem.asset.dimensions, + getScaledDimensions(ctx.draftItem), ctx.draftItem.asset.attachTo as 'wall' | 'wall-side', ctx.draftItem.side, [ctx.draftItem.id], @@ -281,7 +281,7 @@ export const ceilingStrategy = { const ceilingLevelId = resolveLevelId(event.node, nodes) 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 itemHeight = dims[1] @@ -308,7 +308,7 @@ export const ceilingStrategy = { if (ctx.state.surface !== 'ceiling') return null if (!ctx.draftItem) return null - const dims = ctx.asset.dimensions ?? DEFAULT_DIMENSIONS + const dims = getScaledDimensions(ctx.draftItem) const [dimX, , dimZ] = dims const itemHeight = dims[1] @@ -341,7 +341,7 @@ export const ceilingStrategy = { const valid = validators.canPlaceOnCeiling( ctx.state.ceilingId as CeilingNode['id'], pos, - ctx.draftItem.asset.dimensions, + getScaledDimensions(ctx.draftItem), ctx.draftItem.rotation, [ctx.draftItem.id], ).valid @@ -399,8 +399,8 @@ export const itemSurfaceStrategy = { 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 + const ourDims = ctx.draftItem ? getScaledDimensions(ctx.draftItem) : (ctx.asset.dimensions ?? DEFAULT_DIMENSIONS) + const surfDims = getScaledDimensions(surfaceItem) if (ourDims[0] > surfDims[0] || ourDims[2] > surfDims[2]) return null const surfaceMesh = sceneRegistry.nodes.get(surfaceItem.id) @@ -439,7 +439,7 @@ export const itemSurfaceStrategy = { const surfaceMesh = sceneRegistry.nodes.get(ctx.state.surfaceItemId) 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 localPos = surfaceMesh.worldToLocal(worldPos) @@ -501,7 +501,7 @@ export function checkCanPlace(ctx: PlacementContext, validators: SpatialValidato return validators.canPlaceOnCeiling( ctx.state.ceilingId as CeilingNode['id'], [ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z], - ctx.draftItem.asset.dimensions, + getScaledDimensions(ctx.draftItem), ctx.draftItem.rotation, [ctx.draftItem.id], ).valid @@ -514,7 +514,7 @@ export function checkCanPlace(ctx: PlacementContext, validators: SpatialValidato ctx.state.wallId as WallNode['id'], ctx.gridPosition.x, ctx.gridPosition.y, - ctx.draftItem.asset.dimensions, + getScaledDimensions(ctx.draftItem), attachTo, ctx.draftItem.side, [ctx.draftItem.id], @@ -525,7 +525,7 @@ export function checkCanPlace(ctx: PlacementContext, validators: SpatialValidato return validators.canPlaceOnFloor( ctx.levelId, [ctx.gridPosition.x, 0, ctx.gridPosition.z], - ctx.draftItem.asset.dimensions, + getScaledDimensions(ctx.draftItem), [0, 0, 0], [ctx.draftItem.id], ).valid diff --git a/apps/editor/components/tools/item/use-placement-coordinator.tsx b/apps/editor/components/tools/item/use-placement-coordinator.tsx index 7f22a1ee..32054971 100644 --- a/apps/editor/components/tools/item/use-placement-coordinator.tsx +++ b/apps/editor/components/tools/item/use-placement-coordinator.tsx @@ -3,6 +3,7 @@ import { type AnyNodeId, type CeilingEvent, emitter, + getScaledDimensions, type GridEvent, type ItemEvent, resolveLevelId, @@ -646,7 +647,8 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea // ---- 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]) boxGeometry.translate(0, dims[1] / 2, 0) const edgesGeometry = new EdgesGeometry(boxGeometry) @@ -731,7 +733,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea const slabElevation = spatialGridManager.getSlabElevationForItem( levelId, [gridPosition.current.x, gridPosition.current.y, gridPosition.current.z], - asset.dimensions ?? DEFAULT_DIMENSIONS, + getScaledDimensions(draftNode.current), draftNode.current.rotation, ) mesh.position.y = slabElevation diff --git a/apps/editor/components/tools/window/window-math.ts b/apps/editor/components/tools/window/window-math.ts index 25fa6b5e..69882cad 100644 --- a/apps/editor/components/tools/window/window-math.ts +++ b/apps/editor/components/tools/window/window-math.ts @@ -1,4 +1,4 @@ -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 wall base) to world XYZ. @@ -79,7 +79,7 @@ export function hasWallChildOverlap( if (child.type === 'item') { const item = child as ItemNode 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 childRight = item.position[0] + w / 2 childBottom = item.position[1] // items store bottom Y diff --git a/apps/editor/components/ui/panels/item-panel.tsx b/apps/editor/components/ui/panels/item-panel.tsx index 655fc19d..c3c2d87e 100644 --- a/apps/editor/components/ui/panels/item-panel.tsx +++ b/apps/editor/components/ui/panels/item-panel.tsx @@ -1,10 +1,10 @@ '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 { Copy, Move, Trash2, X } from 'lucide-react' +import { Copy, Link, Link2Off, Move, Trash2, X } from 'lucide-react' import Image from 'next/image' -import { useCallback } from 'react' +import { useCallback, useState } from 'react' import useEditor from '@/store/use-editor' import { NumberInput } from '@/components/ui/primitives/number-input' import { sfxEmitter } from '@/lib/sfx-bus' @@ -23,6 +23,8 @@ export function ItemPanel() { ? (nodes[selectedId as AnyNode['id']] as ItemNode | undefined) : undefined + const [uniformScale, setUniformScale] = useState(true) + const handleUpdate = useCallback( (updates: Partial) => { if (!selectedId || !node) return @@ -188,13 +190,75 @@ export function ItemPanel() { - {/* Dimensions (read-only) */} + {/* Scale */} +
+
+ + +
+ {uniformScale ? ( + { + const v = Math.max(0.01, value) + handleUpdate({ scale: [v, v, v] }) + }} + precision={2} + step={0.1} + /> + ) : ( +
+ { + handleUpdate({ scale: [Math.max(0.01, value), node.scale[1], node.scale[2]] }) + }} + precision={2} + step={0.1} + /> + { + handleUpdate({ scale: [node.scale[0], Math.max(0.01, value), node.scale[2]] }) + }} + precision={2} + step={0.1} + /> + { + handleUpdate({ scale: [node.scale[0], node.scale[1], Math.max(0.01, value)] }) + }} + precision={2} + step={0.1} + /> +
+ )} +
+ + {/* Dimensions (effective, read-only) */}
- {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` + })()}
diff --git a/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts b/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts index 9d12f57a..ebd5e6a9 100644 --- a/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts +++ b/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts @@ -1,3 +1,4 @@ +import { getScaledDimensions } from '../../schema' import type { AnyNode, CeilingNode, ItemNode, SlabNode, WallNode } from '../../schema' import { SpatialGrid } from './spatial-grid' import { WallSpatialGrid } from './wall-spatial-grid' @@ -291,7 +292,7 @@ export class SpatialGridManager { if (wallId && this.walls.has(wallId)) { const wallLength = this.getWallLength(wallId) if (wallLength > 0) { - const [width, height] = item.asset.dimensions + const [width, height] = getScaledDimensions(item) const halfW = width / wallLength / 2 // Calculate t from local X position (position[0] is distance along wall) const t = item.position[0] / wallLength @@ -312,7 +313,7 @@ export class SpatialGridManager { // Ceiling item - use parentId as the ceiling ID const ceilingId = item.parentId 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) } } else if (!item.asset.attachTo) { @@ -320,7 +321,7 @@ export class SpatialGridManager { this.getFloorGrid(levelId).insert( item.id, item.position, - item.asset.dimensions, + getScaledDimensions(item), item.rotation, ) } @@ -344,7 +345,7 @@ export class SpatialGridManager { if (wallId && this.walls.has(wallId)) { const wallLength = this.getWallLength(wallId) if (wallLength > 0) { - const [width, height] = item.asset.dimensions + const [width, height] = getScaledDimensions(item) const halfW = width / wallLength / 2 // Calculate t from local X position (position[0] is distance along wall) const t = item.position[0] / wallLength @@ -371,14 +372,14 @@ export class SpatialGridManager { // Insert into new ceiling grid const ceilingId = item.parentId 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) } } else if (!item.asset.attachTo) { this.getFloorGrid(levelId).update( item.id, item.position, - item.asset.dimensions, + getScaledDimensions(item), item.rotation, ) } diff --git a/packages/core/src/hooks/spatial-grid/spatial-grid-sync.ts b/packages/core/src/hooks/spatial-grid/spatial-grid-sync.ts index d978403a..9566159e 100644 --- a/packages/core/src/hooks/spatial-grid/spatial-grid-sync.ts +++ b/packages/core/src/hooks/spatial-grid/spatial-grid-sync.ts @@ -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 { itemOverlapsPolygon, spatialGridManager, wallOverlapsPolygon } from './spatial-grid-manager' @@ -73,11 +73,16 @@ export function initSpatialGridSync() { if ( !arraysEqual(node.position, prev.position) || !arraysEqual(node.rotation, prev.rotation) || + !arraysEqual(node.scale, prev.scale) || node.parentId !== prev.parentId || node.side !== prev.side ) { const levelId = resolveLevelId(node, state.nodes) 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') { if (node.polygon !== prev.polygon || node.elevation !== prev.elevation) { @@ -114,7 +119,7 @@ function markNodesOverlappingSlab( // Only floor items are affected by slabs if (item.asset.attachTo) 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) } } else if (node.type === 'wall') { diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts index 934e89e1..84b4b5d0 100644 --- a/packages/core/src/schema/index.ts +++ b/packages/core/src/schema/index.ts @@ -3,7 +3,7 @@ export { BaseNode, generateId, Material, nodeType, objectId } from './base' // Camera export { CameraSchema } from './camera' export type { AssetInput } from './nodes/item' -export { ItemNode } from './nodes/item' +export { getScaledDimensions, ItemNode } from './nodes/item' export { LevelNode } from './nodes/level' // Nodes export { SiteNode } from './nodes/site' diff --git a/packages/core/src/schema/nodes/item.ts b/packages/core/src/schema/nodes/item.ts index 949b2a91..bda8f4c5 100644 --- a/packages/core/src/schema/nodes/item.ts +++ b/packages/core/src/schema/nodes/item.ts @@ -30,6 +30,7 @@ export const ItemNode = BaseNode.extend({ type: nodeType('item'), 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]), + scale: z.tuple([z.number(), z.number(), z.number()]).default([1, 1, 1]), side: z.enum(['front', 'back']).optional(), children: z.array(objectId('item')).default([]), @@ -53,3 +54,13 @@ export const ItemNode = BaseNode.extend({ `) export type ItemNode = z.infer + +/** + * 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] +} diff --git a/packages/core/src/store/use-scene.ts b/packages/core/src/store/use-scene.ts index d1db3812..3a970b79 100644 --- a/packages/core/src/store/use-scene.ts +++ b/packages/core/src/store/use-scene.ts @@ -68,13 +68,21 @@ const useScene: UseSceneStore = create()( }, 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({ - nodes, + nodes: patchedNodes, rootNodeIds, dirtyNodes: new Set(), }) // Mark all nodes as dirty to trigger re-validation - Object.values(nodes).forEach((node) => { + Object.values(patchedNodes).forEach((node) => { get().markDirty(node.id) }) }, @@ -146,6 +154,7 @@ const useScene: UseSceneStore = create()( ), { name: 'editor-storage', + version: 1, partialize: (state) => ({ nodes: Object.fromEntries( Object.entries(state.nodes).filter(([_, node]) => { @@ -157,6 +166,19 @@ const useScene: UseSceneStore = create()( ), rootNodeIds: state.rootNodeIds, }), + merge: (persistedState, currentState) => { + console.log('merge calling...', persistedState, currentState) + const persisted = persistedState as Partial + // 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) => { console.log('hydrating...') diff --git a/packages/core/src/systems/item/item-system.tsx b/packages/core/src/systems/item/item-system.tsx index ef1dd522..b2876c29 100644 --- a/packages/core/src/systems/item/item-system.tsx +++ b/packages/core/src/systems/item/item-system.tsx @@ -3,7 +3,7 @@ import type * as THREE from 'three' import { sceneRegistry } from '../../hooks/scene-registry/scene-registry' import { spatialGridManager } from '../../hooks/spatial-grid/spatial-grid-manager' 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' // ============================================================================ @@ -43,7 +43,7 @@ export const ItemSystem = () => { const slabElevation = spatialGridManager.getSlabElevationForItem( levelId, item.position, - item.asset.dimensions, + getScaledDimensions(item), item.rotation, ) mesh.position.y = slabElevation + item.position[1] diff --git a/packages/viewer/src/components/renderers/item/item-renderer.tsx b/packages/viewer/src/components/renderers/item/item-renderer.tsx index 7b3156da..0523f4ca 100644 --- a/packages/viewer/src/components/renderers/item/item-renderer.tsx +++ b/packages/viewer/src/components/renderers/item/item-renderer.tsx @@ -40,7 +40,7 @@ export const ItemRenderer = ({ node }: { node: ItemNode }) => { useRegistry(node.id, node.type, ref) return ( - + }>