Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 1 addition & 36 deletions packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ type ItemParentAabb = {
maxZ: number
}

function getFallbackItemLocalBounds(item: ItemNode): ItemLocalBounds {
function getItemLocalBounds(item: ItemNode): ItemLocalBounds {
const [width, height, depth] = getScaledDimensions(item)
const minZ = item.asset.attachTo === 'wall-side' ? -depth : -depth / 2
const maxZ = item.asset.attachTo === 'wall-side' ? 0 : depth / 2
Expand All @@ -76,41 +76,6 @@ function getFallbackItemLocalBounds(item: ItemNode): ItemLocalBounds {
}
}

function getItemLocalBounds(item: ItemNode): ItemLocalBounds {
const metadata =
typeof item.metadata === 'object' && item.metadata !== null && !Array.isArray(item.metadata)
? (item.metadata as Record<string, unknown>)
: null
const rawBounds =
typeof metadata?.meshLocalBounds === 'object' &&
metadata.meshLocalBounds !== null &&
!Array.isArray(metadata.meshLocalBounds)
? (metadata.meshLocalBounds as Record<string, unknown>)
: null
const min = rawBounds?.min
const max = rawBounds?.max

if (
Array.isArray(min) &&
min.length >= 3 &&
Array.isArray(max) &&
max.length >= 3 &&
typeof min[0] === 'number' &&
typeof min[1] === 'number' &&
typeof min[2] === 'number' &&
typeof max[0] === 'number' &&
typeof max[1] === 'number' &&
typeof max[2] === 'number'
) {
return {
min: [min[0], min[1], min[2]],
max: [max[0], max[1], max[2]],
}
}

return getFallbackItemLocalBounds(item)
}

function getItemParentAabb(item: ItemNode): ItemParentAabb {
const bounds = getItemLocalBounds(item)
const corners: Array<[number, number, number]> = [
Expand Down
17 changes: 16 additions & 1 deletion packages/editor/src/components/editor/floorplan-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5181,7 +5181,7 @@ function FloorplanItemImage({
}) {
const resolvedUrl = useResolvedAssetUrl(url)
if (!resolvedUrl) return null
const rotationDeg = (-rotation * 180) / Math.PI
const rotationDeg = (-rotation * 180) / Math.PI + 180
return (
<g
pointerEvents="none"
Expand Down Expand Up @@ -9708,6 +9708,21 @@ export function FloorplanPanel() {
}
}, [isItemPlacementPreviewActive, scheduleMovingFloorplanNodeRefresh])

// Subscribe to the live-transforms store so rotation/position changes that
// *don't* go through pointer events still refresh the floorplan — e.g. R/T
// keyboard rotation during placement updates `useLiveTransforms` but emits
// no grid:move, so without this the floorplan was stale until the user
// moved the cursor.
useEffect(() => {
if (!isItemPlacementPreviewActive) return
const unsubscribe = useLiveTransforms.subscribe((state, prev) => {
if (state.transforms !== prev.transforms) {
scheduleMovingFloorplanNodeRefresh()
}
})
return unsubscribe
}, [isItemPlacementPreviewActive, scheduleMovingFloorplanNodeRefresh])

useEffect(() => {
if (!hasPendingItemMeshFootprints) {
return
Expand Down
12 changes: 6 additions & 6 deletions packages/editor/src/components/editor/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -584,20 +584,20 @@ const ViewerSceneContent = memo(function ViewerSceneContent({
return (
<>
{!isFirstPersonMode && <SelectionManager />}
{!isVersionPreviewMode && !isFirstPersonMode && <BoxSelectTool />}
{!isVersionPreviewMode && !isFirstPersonMode && <FloatingActionMenu />}
{!isVersionPreviewMode && !isFirstPersonMode && <FloatingBuildingActionMenu />}
{!(isVersionPreviewMode || isFirstPersonMode) && <BoxSelectTool />}
{!(isVersionPreviewMode || isFirstPersonMode) && <FloatingActionMenu />}
{!(isVersionPreviewMode || isFirstPersonMode) && <FloatingBuildingActionMenu />}
{!isFirstPersonMode && <WallMeasurementLabel />}
<ExportManager />
{isFirstPersonMode ? <ViewerZoneSystem /> : <ZoneSystem />}
<CeilingSystem />
<CeilingSelectionAffordanceSystem />
<RoofEditSystem />
<StairEditSystem />
{!isLoading && !isFirstPersonMode && (
{!(isLoading || isFirstPersonMode) && (
<Grid cellColor="#aaa" fadeDistance={500} sectionColor="#ccc" />
)}
{!(isLoading || isVersionPreviewMode) && !isFirstPersonMode && <ToolManager />}
{!(isLoading || isVersionPreviewMode || isFirstPersonMode) && <ToolManager />}
{isFirstPersonMode && <FirstPersonControls />}
<CustomCameraControls />
<ThumbnailGenerator onThumbnailCapture={onThumbnailCapture} />
Expand Down Expand Up @@ -1161,7 +1161,7 @@ export default function Editor({
/>
{/* First-person overlay — rendered on top of normal layout */}
{isFirstPersonMode && (
<div className="fixed inset-0 z-50 pointer-events-none">
<div className="pointer-events-none fixed inset-0 z-50">
<FirstPersonOverlay onExit={() => useEditor.getState().setFirstPersonMode(false)} />
</div>
)}
Expand Down
31 changes: 30 additions & 1 deletion packages/editor/src/components/tools/item/placement-math.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { isObject } from '@pascal-app/core'
import { type AssetInput, isObject } from '@pascal-app/core'
import useEditor from '../../../store/use-editor'

function getGridSnapStep(): number {
Expand Down Expand Up @@ -27,6 +27,35 @@ export function snapToHalf(value: number, step = getGridSnapStep()): number {
return Math.round(value / step) * step
}

/**
* Round a value up to the next multiple of `step`, with a minimum of `step`.
*/
export function snapUpToGridStep(value: number, step = getGridSnapStep()): number {
return Math.max(step, Math.ceil(value / step) * step)
}

/**
* Expand an item's scaled dimensions up to the active grid step on the axes
* the placement grid covers. Used for the placement wireframe, snap math, and
* collision against the draft so a small item visually reserves a full grid
* cell.
*
* - Floor / ceiling / item-surface: X + Z (footprint) expand; Y stays exact.
* - Wall / wall-side: X (along wall) + Y (height) expand; Z (depth) stays exact
* so wall-thickness offsets aren't disturbed.
*/
export function getGridAlignedDimensions(
scaledDims: [number, number, number],
attachTo: AssetInput['attachTo'] | null | undefined,
step = getGridSnapStep(),
): [number, number, number] {
const [w, h, d] = scaledDims
if (attachTo === 'wall' || attachTo === 'wall-side') {
return [snapUpToGridStep(w, step), snapUpToGridStep(h, step), d]
}
return [snapUpToGridStep(w, step), h, snapUpToGridStep(d, step)]
}

/**
* Calculate cursor rotation in WORLD space from wall normal and orientation.
*/
Expand Down
58 changes: 39 additions & 19 deletions packages/editor/src/components/tools/item/placement-strategies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,11 @@ import type {
WallNode,
} from '@pascal-app/core'
import { getScaledDimensions, sceneRegistry, useScene } from '@pascal-app/core'
import { Vector3 } from 'three'
import { Euler, Quaternion, Vector3 } from 'three'
import {
calculateCursorRotation,
calculateItemRotation,
getGridAlignedDimensions,
getSideFromNormal,
isValidWallSideFace,
snapToGrid,
Expand Down Expand Up @@ -43,9 +44,10 @@ export const floorStrategy = {
move(ctx: PlacementContext, event: GridEvent): PlacementResult | null {
if (ctx.state.surface !== 'floor') return null

const dims = ctx.draftItem
const rawDims = ctx.draftItem
? getScaledDimensions(ctx.draftItem)
: (ctx.asset.dimensions ?? DEFAULT_DIMENSIONS)
const dims = getGridAlignedDimensions(rawDims, ctx.asset.attachTo)
const [dimX, , dimZ] = dims
const rotY = ctx.draftItem?.rotation?.[1] ?? 0
const swapDims = Math.abs(Math.sin(rotY)) > 0.9
Expand Down Expand Up @@ -80,7 +82,7 @@ export const floorStrategy = {
const valid = validators.canPlaceOnFloor(
ctx.levelId,
pos,
getScaledDimensions(ctx.draftItem),
getGridAlignedDimensions(getScaledDimensions(ctx.draftItem), ctx.draftItem.asset.attachTo),
ctx.draftItem.rotation,
[ctx.draftItem.id],
).valid
Expand Down Expand Up @@ -133,14 +135,15 @@ export const wallStrategy = {
const z = snapToHalf(event.localPosition[2])

// Get auto-adjusted Y position from validator
const rawDims = ctx.draftItem
? getScaledDimensions(ctx.draftItem)
: (ctx.asset.dimensions ?? DEFAULT_DIMENSIONS)
const validation = validators.canPlaceOnWall(
ctx.levelId,
event.node.id,
x,
y,
ctx.draftItem
? getScaledDimensions(ctx.draftItem)
: (ctx.asset.dimensions ?? DEFAULT_DIMENSIONS),
getGridAlignedDimensions(rawDims, attachTo),
attachTo,
side,
[],
Expand Down Expand Up @@ -195,7 +198,7 @@ export const wallStrategy = {
event.node.id,
snappedX,
snappedY,
getScaledDimensions(ctx.draftItem),
getGridAlignedDimensions(getScaledDimensions(ctx.draftItem), ctx.draftItem.asset.attachTo),
ctx.draftItem.asset.attachTo as 'wall' | 'wall-side',
side,
[ctx.draftItem.id],
Expand Down Expand Up @@ -239,7 +242,7 @@ export const wallStrategy = {
ctx.state.wallId as WallNode['id'],
ctx.gridPosition.x,
ctx.gridPosition.y,
getScaledDimensions(ctx.draftItem),
getGridAlignedDimensions(getScaledDimensions(ctx.draftItem), ctx.draftItem.asset.attachTo),
ctx.draftItem.asset.attachTo as 'wall' | 'wall-side',
ctx.draftItem.side,
[ctx.draftItem.id],
Expand Down Expand Up @@ -301,11 +304,12 @@ export const ceilingStrategy = {
const ceilingLevelId = resolveLevelId(event.node, nodes)
if (ctx.levelId !== ceilingLevelId) return null

const dims = ctx.draftItem
const rawDims = ctx.draftItem
? getScaledDimensions(ctx.draftItem)
: (ctx.asset.dimensions ?? DEFAULT_DIMENSIONS)
const dims = getGridAlignedDimensions(rawDims, ctx.asset.attachTo)
const [dimX, , dimZ] = dims
const itemHeight = dims[1]
const itemHeight = rawDims[1]
const rotY = ctx.draftItem?.rotation?.[1] ?? 0
const swapDims = Math.abs(Math.sin(rotY)) > 0.9

Expand Down Expand Up @@ -335,9 +339,10 @@ export const ceilingStrategy = {
if (ctx.state.surface !== 'ceiling') return null
if (!ctx.draftItem) return null

const dims = getScaledDimensions(ctx.draftItem)
const rawDims = getScaledDimensions(ctx.draftItem)
const dims = getGridAlignedDimensions(rawDims, ctx.draftItem.asset.attachTo)
const [dimX, , dimZ] = dims
const itemHeight = dims[1]
const itemHeight = rawDims[1]
const rotY = ctx.draftItem.rotation?.[1] ?? 0
const swapDims = Math.abs(Math.sin(rotY)) > 0.9

Expand Down Expand Up @@ -375,7 +380,7 @@ export const ceilingStrategy = {
const valid = validators.canPlaceOnCeiling(
ctx.state.ceilingId as CeilingNode['id'],
pos,
getScaledDimensions(ctx.draftItem),
getGridAlignedDimensions(getScaledDimensions(ctx.draftItem), ctx.draftItem.asset.attachTo),
ctx.draftItem.rotation,
[ctx.draftItem.id],
).valid
Expand Down Expand Up @@ -453,8 +458,21 @@ export const itemSurfaceStrategy = {

return {
stateUpdate: { surface: 'item-surface', surfaceItemId: surfaceItem.id },
nodeUpdate: { position: [x, y, z], parentId: surfaceItem.id },
cursorRotationY: 0,
nodeUpdate: {
position: [x, y, z],
parentId: surfaceItem.id,
rotation: [
(ctx.draftItem?.rotation ?? [0, 0, 0])[0],
(() => {
const surfaceQuat = new Quaternion()
surfaceMesh.getWorldQuaternion(surfaceQuat)
const surfaceWorldY = new Euler().setFromQuaternion(surfaceQuat, 'YXZ').y
return ctx.currentCursorRotationY - surfaceWorldY
})(),
(ctx.draftItem?.rotation ?? [0, 0, 0])[2],
] as [number, number, number],
},
cursorRotationY: ctx.currentCursorRotationY,
gridPosition: [x, y, z],
cursorPosition: [worldSnapped.x, worldSnapped.y, worldSnapped.z],
stopPropagation: true,
Expand Down Expand Up @@ -488,7 +506,7 @@ export const itemSurfaceStrategy = {
return {
gridPosition: [x, y, z],
cursorPosition: [worldSnapped.x, worldSnapped.y, worldSnapped.z],
cursorRotationY: 0,
cursorRotationY: ctx.currentCursorRotationY,
nodeUpdate: { position: [x, y, z] },
stopPropagation: true,
dirtyNodeId: null,
Expand Down Expand Up @@ -532,12 +550,14 @@ export function checkCanPlace(ctx: PlacementContext, validators: SpatialValidato

const attachTo = ctx.draftItem.asset.attachTo

const alignedDims = getGridAlignedDimensions(getScaledDimensions(ctx.draftItem), attachTo)

if (attachTo === 'ceiling') {
if (ctx.state.surface !== 'ceiling' || !ctx.state.ceilingId) return false
return validators.canPlaceOnCeiling(
ctx.state.ceilingId as CeilingNode['id'],
[ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z],
getScaledDimensions(ctx.draftItem),
alignedDims,
ctx.draftItem.rotation,
[ctx.draftItem.id],
).valid
Expand All @@ -550,7 +570,7 @@ export function checkCanPlace(ctx: PlacementContext, validators: SpatialValidato
ctx.state.wallId as WallNode['id'],
ctx.gridPosition.x,
ctx.gridPosition.y,
getScaledDimensions(ctx.draftItem),
alignedDims,
attachTo,
ctx.draftItem.side,
[ctx.draftItem.id],
Expand All @@ -561,7 +581,7 @@ export function checkCanPlace(ctx: PlacementContext, validators: SpatialValidato
return validators.canPlaceOnFloor(
ctx.levelId,
[ctx.gridPosition.x, 0, ctx.gridPosition.z],
getScaledDimensions(ctx.draftItem),
alignedDims,
ctx.draftItem.rotation,
[ctx.draftItem.id],
).valid
Expand Down
7 changes: 7 additions & 0 deletions packages/editor/src/components/tools/item/placement-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,13 @@ export interface PlacementContext {
draftItem: ItemNode | null
gridPosition: Vector3
state: PlacementState
/**
* Current world Y rotation of the placement cursor — the user's intended
* orientation, preserved across surface transitions. Strategies that
* re-parent the draft (e.g. floor → item-surface) read this to compute the
* matching parent-local rotation so the world orientation doesn't jump.
*/
currentCursorRotationY: number
}

// ============================================================================
Expand Down
Loading
Loading