item scale

This commit is contained in:
wass08
2026-02-23 10:59:42 +09:00
parent 3894d7c8bf
commit d15f601107
11 changed files with 143 additions and 38 deletions
@@ -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
@@ -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
@@ -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
@@ -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<ItemNode>) => {
if (!selectedId || !node) return
@@ -188,13 +190,75 @@ export function ItemPanel() {
</div>
</div>
{/* Dimensions (read-only) */}
{/* Scale */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
Scale
</label>
<button
type="button"
className="rounded p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground cursor-pointer"
onClick={() => setUniformScale((v) => !v)}
title={uniformScale ? 'Unlock axes' : 'Lock axes'}
>
{uniformScale ? <Link className="h-3.5 w-3.5" /> : <Link2Off className="h-3.5 w-3.5" />}
</button>
</div>
{uniformScale ? (
<NumberInput
label="XYZ"
value={Math.round(node.scale[0] * 100) / 100}
onChange={(value) => {
const v = Math.max(0.01, value)
handleUpdate({ scale: [v, v, v] })
}}
precision={2}
step={0.1}
/>
) : (
<div className="grid grid-cols-3 gap-2">
<NumberInput
label="X"
value={Math.round(node.scale[0] * 100) / 100}
onChange={(value) => {
handleUpdate({ scale: [Math.max(0.01, value), node.scale[1], node.scale[2]] })
}}
precision={2}
step={0.1}
/>
<NumberInput
label="Y"
value={Math.round(node.scale[1] * 100) / 100}
onChange={(value) => {
handleUpdate({ scale: [node.scale[0], Math.max(0.01, value), node.scale[2]] })
}}
precision={2}
step={0.1}
/>
<NumberInput
label="Z"
value={Math.round(node.scale[2] * 100) / 100}
onChange={(value) => {
handleUpdate({ scale: [node.scale[0], node.scale[1], Math.max(0.01, value)] })
}}
precision={2}
step={0.1}
/>
</div>
)}
</div>
{/* Dimensions (effective, read-only) */}
<div className="space-y-2">
<label className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
Dimensions
</label>
<div className="rounded border border-border bg-muted/50 px-3 py-2 text-sm">
{node.asset.dimensions[0]}m × {node.asset.dimensions[1]}m × {node.asset.dimensions[2]}m
{(() => {
const [w, h, d] = getScaledDimensions(node)
return `${Math.round(w * 100) / 100}m × ${Math.round(h * 100) / 100}m × ${Math.round(d * 100) / 100}m`
})()}
</div>
</div>
</div>
@@ -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,
)
}
@@ -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') {
+1 -1
View File
@@ -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'
+11
View File
@@ -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<typeof ItemNode>
/**
* Returns the effective world-space dimensions of an item after applying its scale.
* Use this everywhere item.asset.dimensions is used for spatial calculations.
*/
export function getScaledDimensions(item: ItemNode): [number, number, number] {
const [w, h, d] = item.asset.dimensions
const [sx, sy, sz] = item.scale
return [w * sx, h * sy, d * sz]
}
+24 -2
View File
@@ -68,13 +68,21 @@ const useScene: UseSceneStore = create<SceneState>()(
},
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<AnyNodeId>(),
})
// 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<SceneState>()(
),
{
name: 'editor-storage',
version: 1,
partialize: (state) => ({
nodes: Object.fromEntries(
Object.entries(state.nodes).filter(([_, node]) => {
@@ -157,6 +166,19 @@ const useScene: UseSceneStore = create<SceneState>()(
),
rootNodeIds: state.rootNodeIds,
}),
merge: (persistedState, currentState) => {
console.log('merge calling...', persistedState, currentState)
const persisted = persistedState as Partial<SceneState>
// Backward compat: add default scale to item nodes saved before scale was added
if (persisted.nodes) {
for (const [id, node] of Object.entries(persisted.nodes)) {
if (node.type === 'item' && !('scale' in node)) {
persisted.nodes[id as AnyNodeId] = { ...(node as object), scale: [1, 1, 1] } as AnyNode
}
}
}
return { ...currentState, ...persisted }
},
onRehydrateStorage: (state) => {
console.log('hydrating...')
@@ -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]
@@ -40,7 +40,7 @@ export const ItemRenderer = ({ node }: { node: ItemNode }) => {
useRegistry(node.id, node.type, ref)
return (
<group position={node.position} rotation={node.rotation} ref={ref} visible={node.visible}>
<group position={node.position} rotation={node.rotation} scale={node.scale} ref={ref} visible={node.visible}>
<Suspense fallback={<PreviewModel node={node} />}>
<ModelRenderer node={node} />
</Suspense>