From 6c9985100634a79775f120a1761a7ae7ca4bc5c6 Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Fri, 30 Jan 2026 22:01:25 -0500 Subject: [PATCH] Refactor asset types and improve UI primitives Replaces usage of the Asset type with AssetInput across editor components and hooks for consistency with core types. Adds default dimensions fallback for asset placement logic. Refactors UI primitives (button, sidebar, opacity control) to support forwarding refs and asChild prop, and updates imports for primitives. Updates Radix UI dependencies in package.json and bun.lock. --- .../editor/app/viewer/[id]/viewer-overlay.tsx | 8 +- .../app/viewer/[id]/viewer-zone-system.tsx | 2 +- .../tools/item/placement-strategies.ts | 15 +- .../components/tools/item/placement-types.ts | 5 +- .../components/tools/item/use-draft-node.ts | 6 +- .../tools/item/use-placement-coordinator.tsx | 17 +- .../components/tools/slab/slab-tool.tsx | 4 +- .../components/ui/primitives/button.tsx | 15 +- .../ui/primitives/opacity-control.tsx | 10 +- .../components/ui/primitives/sidebar.tsx | 148 +++++++++++++----- apps/editor/hooks/use-keyboard.ts | 4 +- apps/editor/package.json | 7 + apps/editor/store/use-editor.tsx | 6 +- bun.lock | 105 ++++++++----- package.json | 12 +- .../core/src/store/actions/node-actions.ts | 31 ++-- packages/core/src/store/use-scene.ts | 9 +- packages/viewer/src/store/use-viewer.ts | 8 +- turbo.json | 2 +- 19 files changed, 272 insertions(+), 142 deletions(-) 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, - )} +