diff --git a/apps/editor/app/viewer/[id]/viewer-overlay.tsx b/apps/editor/app/viewer/[id]/viewer-overlay.tsx index 3c6004f2..f88effef 100644 --- a/apps/editor/app/viewer/[id]/viewer-overlay.tsx +++ b/apps/editor/app/viewer/[id]/viewer-overlay.tsx @@ -1,6 +1,6 @@ 'use client' -import { type AnyNode, type BuildingNode, type LevelNode, type ZoneNode, useScene } from '@pascal-app/core' +import { type AnyNode, type AnyNodeId, type BuildingNode, type LevelNode, type ZoneNode, useScene } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { Box, ChevronRight, Diamond, Eye, EyeOff, Image, Layers, Layers2 } from 'lucide-react' @@ -28,16 +28,16 @@ export const ViewerOverlay = () => { // Get the first selected item (if any) const selectedNode = selection.selectedIds.length > 0 - ? (nodes[selection.selectedIds[0]!] as AnyNode | undefined) + ? (nodes[selection.selectedIds[0] as AnyNodeId] as AnyNode | undefined) : null // Get all levels for the selected building const levels = building?.children - .map((id) => nodes[id] as LevelNode | undefined) + .map((id) => nodes[id as AnyNodeId] as LevelNode | undefined) .filter((n): n is LevelNode => n?.type === 'level') .sort((a, b) => a.level - b.level) ?? [] - const handleLevelClick = (levelId: string) => { + const handleLevelClick = (levelId: LevelNode['id']) => { // When switching levels, deselect zone and items useViewer.getState().setSelection({ levelId }) } diff --git a/apps/editor/app/viewer/[id]/viewer-zone-system.tsx b/apps/editor/app/viewer/[id]/viewer-zone-system.tsx index 7ab68dc0..5f21786e 100644 --- a/apps/editor/app/viewer/[id]/viewer-zone-system.tsx +++ b/apps/editor/app/viewer/[id]/viewer-zone-system.tsx @@ -21,7 +21,7 @@ export const ViewerZoneSystem = () => { // 2. Zone is not on the selected level // 3. A zone is already selected (hide all zones to show zone contents) const isOnSelectedLevel = zone.parentId === levelId - const shouldShow = levelId && isOnSelectedLevel && !zoneId + const shouldShow = !!levelId && isOnSelectedLevel && !zoneId obj.visible = shouldShow diff --git a/apps/editor/components/tools/item/placement-strategies.ts b/apps/editor/components/tools/item/placement-strategies.ts index 8224e75b..3fd7f501 100644 --- a/apps/editor/components/tools/item/placement-strategies.ts +++ b/apps/editor/components/tools/item/placement-strategies.ts @@ -24,6 +24,8 @@ import { stripTransient, } from './placement-math' +const DEFAULT_DIMENSIONS: [number, number, number] = [1, 1, 1] + // ============================================================================ // FLOOR STRATEGY // ============================================================================ @@ -36,7 +38,8 @@ export const floorStrategy = { move(ctx: PlacementContext, event: GridEvent): PlacementResult | null { if (ctx.state.surface !== 'floor') return null - const [dimX, , dimZ] = ctx.asset.dimensions + const dims = ctx.asset.dimensions ?? DEFAULT_DIMENSIONS + const [dimX, , dimZ] = dims const x = snapToGrid(event.position[0], dimX) const z = snapToGrid(event.position[2], dimZ) @@ -240,8 +243,9 @@ export const ceilingStrategy = { const ceilingLevelId = resolveLevelId(event.node, nodes) if (ctx.levelId !== ceilingLevelId) return null - const [dimX, , dimZ] = ctx.asset.dimensions - const itemHeight = ctx.asset.dimensions[1] + const dims = ctx.asset.dimensions ?? DEFAULT_DIMENSIONS + const [dimX, , dimZ] = dims + const itemHeight = dims[1] const x = snapToGrid(event.position[0], dimX) const z = snapToGrid(event.position[2], dimZ) @@ -266,8 +270,9 @@ export const ceilingStrategy = { if (ctx.state.surface !== 'ceiling') return null if (!ctx.draftItem) return null - const [dimX, , dimZ] = ctx.asset.dimensions - const itemHeight = ctx.asset.dimensions[1] + const dims = ctx.asset.dimensions ?? DEFAULT_DIMENSIONS + const [dimX, , dimZ] = dims + const itemHeight = dims[1] const x = snapToGrid(event.position[0], dimX) const z = snapToGrid(event.position[2], dimZ) diff --git a/apps/editor/components/tools/item/placement-types.ts b/apps/editor/components/tools/item/placement-types.ts index a0e7e806..a9610a3f 100644 --- a/apps/editor/components/tools/item/placement-types.ts +++ b/apps/editor/components/tools/item/placement-types.ts @@ -1,6 +1,5 @@ -import type { AnyNode, CeilingNode, ItemNode, LevelNode, WallNode } from '@pascal-app/core' +import type { AnyNode, AssetInput, CeilingNode, ItemNode, LevelNode, WallNode } from '@pascal-app/core' import type { Vector3 } from 'three' -import type { Asset } from '../../../../../packages/core/src/schema/nodes/item' // ============================================================================ // PLACEMENT STATE @@ -26,7 +25,7 @@ export interface PlacementState { * Read-only snapshot passed to every strategy call. */ export interface PlacementContext { - asset: Asset + asset: AssetInput levelId: LevelNode['id'] | null draftItem: ItemNode | null gridPosition: Vector3 diff --git a/apps/editor/components/tools/item/use-draft-node.ts b/apps/editor/components/tools/item/use-draft-node.ts index 9286b37e..ff9aeed3 100644 --- a/apps/editor/components/tools/item/use-draft-node.ts +++ b/apps/editor/components/tools/item/use-draft-node.ts @@ -2,7 +2,7 @@ import { type AnyNodeId, ItemNode, useScene } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { useCallback, useMemo, useRef } from 'react' import type { Vector3 } from 'three' -import type { Asset } from '../../../../../packages/core/src/schema/nodes/item' +import type { AssetInput } from '@pascal-app/core' import { stripTransient } from './placement-math' interface OriginalState { @@ -19,7 +19,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: Asset, rotation?: [number, number, number]) => ItemNode | null + create: (gridPosition: Vector3, asset: AssetInput, rotation?: [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. */ @@ -41,7 +41,7 @@ export function useDraftNode(): DraftNodeHandle { const adoptedRef = useRef(false) const originalStateRef = useRef(null) - const create = useCallback((gridPosition: Vector3, asset: Asset, rotation?: [number, number, number]): ItemNode | null => { + const create = useCallback((gridPosition: Vector3, asset: AssetInput, rotation?: [number, number, number]): ItemNode | null => { const currentLevelId = useViewer.getState().selection.levelId if (!currentLevelId) return null diff --git a/apps/editor/components/tools/item/use-placement-coordinator.tsx b/apps/editor/components/tools/item/use-placement-coordinator.tsx index cea1c1b7..34d02fae 100644 --- a/apps/editor/components/tools/item/use-placement-coordinator.tsx +++ b/apps/editor/components/tools/item/use-placement-coordinator.tsx @@ -23,10 +23,12 @@ import { } from './placement-strategies' import type { PlacementState, TransitionResult } from './placement-types' import type { DraftNodeHandle } from './use-draft-node' -import type { Asset } from '../../../../../packages/core/src/schema/nodes/item' +import type { AssetInput } from '@pascal-app/core' + +const DEFAULT_DIMENSIONS: [number, number, number] = [1, 1, 1] export interface PlacementCoordinatorConfig { - asset: Asset + asset: AssetInput draftNode: DraftNodeHandle initDraft: (gridPosition: Vector3) => void onCommitted: () => boolean @@ -441,12 +443,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea // ---- Bounding box geometry ---- - const boxGeometry = new BoxGeometry( - asset.dimensions[0], - asset.dimensions[1], - asset.dimensions[2], - ) - boxGeometry.translate(0, asset.dimensions[1] / 2, 0) + const dims = asset.dimensions ?? DEFAULT_DIMENSIONS + const boxGeometry = new BoxGeometry(dims[0], dims[1], dims[2]) + boxGeometry.translate(0, dims[1] / 2, 0) cursorRef.current.geometry = boxGeometry // ---- Subscribe ---- @@ -507,7 +506,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea mesh.position.y = spatialGridManager.getSlabElevationForItem( levelId, [gridPosition.current.x, gridPosition.current.y, gridPosition.current.z], - asset.dimensions, + asset.dimensions ?? DEFAULT_DIMENSIONS, draftNode.current.rotation, ) } diff --git a/apps/editor/components/tools/slab/slab-tool.tsx b/apps/editor/components/tools/slab/slab-tool.tsx index 067f165a..5f915278 100644 --- a/apps/editor/components/tools/slab/slab-tool.tsx +++ b/apps/editor/components/tools/slab/slab-tool.tsx @@ -218,7 +218,7 @@ export const SlabTool: React.FC = () => { // Reset state pointsRef.current = []; - setPreview({ points: [], cursorPoint: null }); + setPreview({ points: [], cursorPoint: null, levelY: 0 }); mainLineRef.current.visible = false; closingLineRef.current.visible = false; @@ -240,7 +240,7 @@ export const SlabTool: React.FC = () => { // Reset state pointsRef.current = []; - setPreview({ points: [], cursorPoint: null }); + setPreview({ points: [], cursorPoint: null, levelY: 0 }); mainLineRef.current.visible = false; closingLineRef.current.visible = false; diff --git a/apps/editor/components/ui/primitives/button.tsx b/apps/editor/components/ui/primitives/button.tsx index afe62811..e917d924 100644 --- a/apps/editor/components/ui/primitives/button.tsx +++ b/apps/editor/components/ui/primitives/button.tsx @@ -39,17 +39,28 @@ function Button({ variant, size, asChild = false, + ref, ...props }: React.ComponentProps<'button'> & VariantProps & { asChild?: boolean }) { - const Comp = asChild ? Slot : 'button' + if (asChild) { + return ( + + ) + } return ( - ) diff --git a/apps/editor/components/ui/primitives/opacity-control.tsx b/apps/editor/components/ui/primitives/opacity-control.tsx index bbb2b722..cbd51042 100644 --- a/apps/editor/components/ui/primitives/opacity-control.tsx +++ b/apps/editor/components/ui/primitives/opacity-control.tsx @@ -2,9 +2,9 @@ import { Eye, EyeOff } from 'lucide-react' import { useState } from 'react' -import { Button } from '@/components/ui/button' -import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' -import { Slider } from '@/components/ui/slider' +import { Button } from '@/components/ui/primitives/button' +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/primitives/popover' +import { Slider } from '@/components/ui/primitives/slider' import { cn } from '@/lib/utils' interface OpacityControlProps { @@ -65,8 +65,8 @@ export function OpacityControl({ { - onOpacityChange(value) + onValueChange={(values: number[]) => { + if (values[0] !== undefined) onOpacityChange(values[0]) }} step={1} value={[actualOpacity]} diff --git a/apps/editor/components/ui/primitives/sidebar.tsx b/apps/editor/components/ui/primitives/sidebar.tsx index f4b4d8f4..2ba3ea30 100644 --- a/apps/editor/components/ui/primitives/sidebar.tsx +++ b/apps/editor/components/ui/primitives/sidebar.tsx @@ -397,12 +397,27 @@ function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) { function SidebarGroupLabel({ className, asChild = false, + ref, ...props }: React.ComponentProps<"div"> & { asChild?: boolean }) { - const Comp = asChild ? Slot : "div"; + if (asChild) { + return ( + svg]:size-4 [&>svg]:shrink-0", + "group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0", + className, + )} + data-sidebar="group-label" + data-slot="sidebar-group-label" + ref={ref as never} + {...props} + /> + ); + } return ( - svg]:size-4 [&>svg]:shrink-0", "group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0", @@ -410,6 +425,7 @@ function SidebarGroupLabel({ )} data-sidebar="group-label" data-slot="sidebar-group-label" + ref={ref} {...props} /> ); @@ -418,21 +434,34 @@ function SidebarGroupLabel({ function SidebarGroupAction({ className, asChild = false, + ref, ...props }: React.ComponentProps<"button"> & { asChild?: boolean }) { - const Comp = asChild ? Slot : "button"; + const classes = cn( + "absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-hidden ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0", + "after:-inset-2 after:absolute md:after:hidden", + "group-data-[collapsible=icon]:hidden", + className, + ); + + if (asChild) { + return ( + + ); + } return ( - svg]:size-4 [&>svg]:shrink-0", - // Increases the hit area of the button on mobile. - "after:-inset-2 after:absolute md:after:hidden", - "group-data-[collapsible=icon]:hidden", - className, - )} +