diff --git a/apps/editor/components/tools/item/move-tool.tsx b/apps/editor/components/tools/item/move-tool.tsx index 7d73c926..c3cc7c38 100644 --- a/apps/editor/components/tools/item/move-tool.tsx +++ b/apps/editor/components/tools/item/move-tool.tsx @@ -34,13 +34,15 @@ function MoveItemContent({ movingNode }: { movingNode: ItemNode }) { draftNode, // 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), + // Preserve the original item's scale so Y-position calculations use the correct height + defaultScale: isNew ? movingNode.scale : undefined, initDraft: (gridPosition) => { if (isNew) { // 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. gridPosition.copy(new Vector3(...movingNode.position)) if (!movingNode.asset.attachTo) { - draftNode.create(gridPosition, movingNode.asset, movingNode.rotation) + draftNode.create(gridPosition, movingNode.asset, movingNode.rotation, movingNode.scale) } } else { draftNode.adopt(movingNode) diff --git a/apps/editor/components/tools/item/placement-strategies.ts b/apps/editor/components/tools/item/placement-strategies.ts index 08b782ef..69daf861 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) @@ -411,7 +411,7 @@ export const itemSurfaceStrategy = { const x = snapToGrid(localPos.x, ourDims[0]) 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)) @@ -439,13 +439,13 @@ 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) const x = snapToGrid(localPos.x, ourDims[0]) 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)) @@ -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-draft-node.ts b/apps/editor/components/tools/item/use-draft-node.ts index 07213229..4d52a944 100644 --- a/apps/editor/components/tools/item/use-draft-node.ts +++ b/apps/editor/components/tools/item/use-draft-node.ts @@ -18,7 +18,7 @@ export interface DraftNodeHandle { /** Whether the current draft was adopted (move mode) vs created (create mode) */ readonly isAdopted: boolean /** 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). */ adopt: (node: ItemNode) => void /** 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 originalStateRef = useRef(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 if (!currentLevelId) return null const node = ItemNode.parse({ position: [gridPosition.x, gridPosition.y, gridPosition.z], rotation: rotation ?? [0, 0, 0], + scale: scale ?? [1, 1, 1], name: asset.name, asset, parentId: currentLevelId, diff --git a/apps/editor/components/tools/item/use-placement-coordinator.tsx b/apps/editor/components/tools/item/use-placement-coordinator.tsx index 7f22a1ee..dcd151e9 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, @@ -65,6 +66,8 @@ export interface PlacementCoordinatorConfig { onCommitted: () => boolean onCancel?: () => void 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 { @@ -139,7 +142,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea cursorGroupRef.current.position.set(...result.cursorPosition) 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 if (draft) { @@ -646,7 +649,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 +735,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 @@ -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]) initialBoxGeometry.translate(0, dims[1] / 2, 0) diff --git a/apps/editor/components/tools/window/move-window-tool.tsx b/apps/editor/components/tools/window/move-window-tool.tsx index be2d343f..63bccc95 100644 --- a/apps/editor/components/tools/window/move-window-tool.tsx +++ b/apps/editor/components/tools/window/move-window-tool.tsx @@ -2,6 +2,7 @@ import { type AnyNodeId, emitter, sceneRegistry, + spatialGridManager, useScene, type WallEvent, WindowNode, @@ -78,10 +79,17 @@ export const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWin if (wallId) useScene.getState().dirtyNodes.add(wallId as AnyNodeId) } + const getLevelId = () => useViewer.getState().selection.levelId const getLevelYOffset = () => { - const id = useViewer.getState().selection.levelId + const id = getLevelId() 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 = () => { if (cursorGroupRef.current) cursorGroupRef.current.visible = false @@ -102,6 +110,8 @@ export const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWin const onWallEnter = (event: WallEvent) => { 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 itemRotation = calculateItemRotation(event.normal) @@ -134,12 +144,18 @@ export const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWin movingWindowNode.id, ) - updateCursor(wallLocalToWorld(event.node, clampedX, clampedY, getLevelYOffset()), cursorRotation, valid) + updateCursor( + wallLocalToWorld(event.node, clampedX, clampedY, getLevelYOffset(), getSlabElevation(event)), + cursorRotation, + valid, + ) event.stopPropagation() } const onWallMove = (event: WallEvent) => { 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 itemRotation = calculateItemRotation(event.normal) @@ -172,12 +188,18 @@ export const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWin movingWindowNode.id, ) - updateCursor(wallLocalToWorld(event.node, clampedX, clampedY, getLevelYOffset()), cursorRotation, valid) + updateCursor( + wallLocalToWorld(event.node, clampedX, clampedY, getLevelYOffset(), getSlabElevation(event)), + cursorRotation, + valid, + ) event.stopPropagation() } const onWallClick = (event: WallEvent) => { 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 itemRotation = calculateItemRotation(event.normal) diff --git a/apps/editor/components/tools/window/window-math.ts b/apps/editor/components/tools/window/window-math.ts index 5419dfd4..69882cad 100644 --- a/apps/editor/components/tools/window/window-math.ts +++ b/apps/editor/components/tools/window/window-math.ts @@ -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). - * Pass levelYOffset (the level group's current world Y) so the cursor lands at the - * correct world height when the cursor group is at the scene root. + * Pass levelYOffset (the level group's current world Y) and slabElevation (the + * 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( wallNode: WallNode, localX: number, localY: number, levelYOffset = 0, + slabElevation = 0, ): [number, number, number] { const wallAngle = Math.atan2( wallNode.end[1] - wallNode.start[1], @@ -18,7 +20,7 @@ export function wallLocalToWorld( ) return [ wallNode.start[0] + localX * Math.cos(wallAngle), - localY + levelYOffset, + slabElevation + localY + levelYOffset, wallNode.start[1] + localX * Math.sin(wallAngle), ] } @@ -77,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/tools/window/window-tool.tsx b/apps/editor/components/tools/window/window-tool.tsx index 3d275456..419deea8 100644 --- a/apps/editor/components/tools/window/window-tool.tsx +++ b/apps/editor/components/tools/window/window-tool.tsx @@ -2,6 +2,7 @@ import { type AnyNodeId, emitter, sceneRegistry, + spatialGridManager, useScene, type WallEvent, WindowNode, @@ -44,6 +45,12 @@ export const WindowTool: React.FC = () => { const id = getLevelId() 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) => { useScene.getState().dirtyNodes.add(wallId as AnyNodeId) @@ -79,6 +86,8 @@ export const WindowTool: React.FC = () => { if (!isValidWallSideFace(event.normal)) return const levelId = getLevelId() if (!levelId) return + // Only interact with walls on the current level + if (event.node.parentId !== levelId) return destroyDraft() @@ -108,12 +117,18 @@ export const WindowTool: React.FC = () => { 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() } const onWallMove = (event: WallEvent) => { 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 itemRotation = calculateItemRotation(event.normal) @@ -142,13 +157,19 @@ export const WindowTool: React.FC = () => { 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() } const onWallClick = (event: WallEvent) => { if (!draftRef.current) 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 itemRotation = calculateItemRotation(event.normal) 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 8138efe9..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' @@ -183,11 +184,24 @@ export function wallOverlapsPolygon( end: [number, number], polygon: Array<[number, number]>, ): boolean { - const startInside = pointInPolygon(start[0], start[1], polygon) - const endInside = pointInPolygon(end[0], end[1], polygon) + const dx = end[0] - start[0] + const dz = end[1] - start[1] + const len = Math.sqrt(dx * dx + dz * dz) - // At least one endpoint strictly inside the polygon - if (startInside || endInside) return true + // Nudge endpoint test points a tiny step inward along the wall direction before + // 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) const midX = (start[0] + end[0]) / 2 @@ -278,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 @@ -299,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) { @@ -307,7 +321,7 @@ export class SpatialGridManager { this.getFloorGrid(levelId).insert( item.id, item.position, - item.asset.dimensions, + getScaledDimensions(item), item.rotation, ) } @@ -331,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 @@ -358,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 5a70bacc..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] @@ -51,7 +51,7 @@ export const ItemSystem = () => { } clearDirty(id as AnyNodeId) - }) + }, 2) }) return null diff --git a/packages/core/src/systems/slab/slab-system.tsx b/packages/core/src/systems/slab/slab-system.tsx index 29a2c8b6..29aae139 100644 --- a/packages/core/src/systems/slab/slab-system.tsx +++ b/packages/core/src/systems/slab/slab-system.tsx @@ -29,7 +29,7 @@ export const SlabSystem = () => { } // If mesh not found, keep it dirty for next frame }) - }) + }, 1) return null } diff --git a/packages/core/src/systems/wall/wall-system.tsx b/packages/core/src/systems/wall/wall-system.tsx index 13589518..e741519a 100644 --- a/packages/core/src/systems/wall/wall-system.tsx +++ b/packages/core/src/systems/wall/wall-system.tsx @@ -1,7 +1,7 @@ import { useFrame } from '@react-three/fiber' import * as THREE from 'three' -import { computeBoundsTree } from 'three-mesh-bvh' import { Brush, Evaluator, SUBTRACTION } from 'three-bvh-csg' +import { computeBoundsTree } from 'three-mesh-bvh' 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' @@ -22,7 +22,7 @@ const csgEvaluator = new Evaluator() // WALL SYSTEM // ============================================================================ -let useFrameNb = 0; +let useFrameNb = 0 export const WallSystem = () => { const dirtyNodes = useScene((state) => state.dirtyNodes) const clearDirty = useScene((state) => state.clearDirty) @@ -35,7 +35,7 @@ export const WallSystem = () => { // Collect dirty walls and their levels const dirtyWallsByLevel = new Map>() - useFrameNb += 1; + useFrameNb += 1 dirtyNodes.forEach((id) => { const node = nodes[id] if (!node || node.type !== 'wall') return @@ -75,7 +75,7 @@ export const WallSystem = () => { } } } - }) + }, 4) return null } @@ -108,7 +108,6 @@ function updateWallGeometry(wallId: string, miterData: WallMiterData) { const node = nodes[wallId as WallNode['id']] if (!node || node.type !== 'wall') return - const mesh = sceneRegistry.nodes.get(wallId) as THREE.Mesh if (!mesh) return diff --git a/packages/core/src/systems/window/window-system.tsx b/packages/core/src/systems/window/window-system.tsx index 57180af5..c533dd2b 100644 --- a/packages/core/src/systems/window/window-system.tsx +++ b/packages/core/src/systems/window/window-system.tsx @@ -50,7 +50,7 @@ export const WindowSystem = () => { useScene.getState().dirtyNodes.add((node as WindowNode).parentId as AnyNodeId) } }) - }) + }, 3) return null } diff --git a/packages/viewer/src/components/renderers/item/item-renderer.tsx b/packages/viewer/src/components/renderers/item/item-renderer.tsx index 7b3156da..60f6a731 100644 --- a/packages/viewer/src/components/renderers/item/item-renderer.tsx +++ b/packages/viewer/src/components/renderers/item/item-renderer.tsx @@ -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 { scene, nodes } = useGLTF(resolveCdnUrl(node.asset.src) || '') @@ -115,7 +118,7 @@ const ModelRenderer = ({ node }: { node: ItemNode }) => { return ( maxTop) maxTop = top } @@ -78,11 +81,11 @@ export const LevelSystem = () => { const explodedExtra = levelMode === 'exploded' ? index * EXPLODED_GAP : 0 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 cumulativeY += getLevelHeight(levelId, nodes) } - }) + }, 5) // Using a lower priority so it runs after transforms from other systems have settled return null }