Refactor editor workflow and update UI state handling

This commit is contained in:
sudhir
2026-04-30 13:37:52 +05:30
parent 3d0000f93f
commit 8631622e60
9 changed files with 1229 additions and 108 deletions
+9 -1
View File
@@ -1,11 +1,12 @@
import type { ThreeEvent } from '@react-three/fiber' import type { ThreeEvent } from '@react-three/fiber'
import type { Object3D } from 'three'
import mitt from 'mitt' import mitt from 'mitt'
import type { Object3D } from 'three'
import type { import type {
BuildingNode, BuildingNode,
CeilingNode, CeilingNode,
DoorNode, DoorNode,
FenceNode, FenceNode,
GuideNode,
ItemNode, ItemNode,
LevelNode, LevelNode,
RoofNode, RoofNode,
@@ -130,6 +131,12 @@ type ToolEvents = {
'tool:cancel': undefined 'tool:cancel': undefined
} }
type GuideEvents = {
'guide:set-reference-scale': { guideId: GuideNode['id'] }
'guide:cancel-reference-scale': undefined
'guide:deleted': { guideId: GuideNode['id'] }
}
type PresetEvents = { type PresetEvents = {
'preset:generate-thumbnail': { presetId: string; nodeId: string } 'preset:generate-thumbnail': { presetId: string; nodeId: string }
'preset:thumbnail-updated': { presetId: string; thumbnailUrl: string } 'preset:thumbnail-updated': { presetId: string; thumbnailUrl: string }
@@ -169,6 +176,7 @@ type EditorEvents = GridEvents &
NodeEvents<'door', DoorEvent> & NodeEvents<'door', DoorEvent> &
CameraControlEvents & CameraControlEvents &
ToolEvents & ToolEvents &
GuideEvents &
PresetEvents & PresetEvents &
ThumbnailEvents & ThumbnailEvents &
SnapshotEvents & SnapshotEvents &
+3 -3
View File
@@ -28,7 +28,7 @@ export { BuildingNode } from './nodes/building'
export { CeilingNode } from './nodes/ceiling' export { CeilingNode } from './nodes/ceiling'
export { DoorNode, DoorSegment } from './nodes/door' export { DoorNode, DoorSegment } from './nodes/door'
export { FenceBaseStyle, FenceNode, FenceStyle } from './nodes/fence' export { FenceBaseStyle, FenceNode, FenceStyle } from './nodes/fence'
export { GuideNode } from './nodes/guide' export { GuideNode, GuideScaleReference } from './nodes/guide'
export type { export type {
AnimationEffect, AnimationEffect,
Asset, Asset,
@@ -43,13 +43,14 @@ export type {
} from './nodes/item' } from './nodes/item'
export { getScaledDimensions, ItemNode } from './nodes/item' export { getScaledDimensions, ItemNode } from './nodes/item'
export { LevelNode } from './nodes/level' export { LevelNode } from './nodes/level'
export { getEffectiveRoofSurfaceMaterial, RoofNode } from './nodes/roof'
export type { RoofSurfaceMaterialRole, RoofSurfaceMaterialSpec } from './nodes/roof' export type { RoofSurfaceMaterialRole, RoofSurfaceMaterialSpec } from './nodes/roof'
export { getEffectiveRoofSurfaceMaterial, RoofNode } from './nodes/roof'
export { RoofSegmentNode, RoofType } from './nodes/roof-segment' export { RoofSegmentNode, RoofType } from './nodes/roof-segment'
export { ScanNode } from './nodes/scan' export { ScanNode } from './nodes/scan'
// Nodes // Nodes
export { SiteNode } from './nodes/site' export { SiteNode } from './nodes/site'
export { SlabNode } from './nodes/slab' export { SlabNode } from './nodes/slab'
export type { StairSurfaceMaterialRole, StairSurfaceMaterialSpec } from './nodes/stair'
export { export {
getEffectiveStairSurfaceMaterial, getEffectiveStairSurfaceMaterial,
StairNode, StairNode,
@@ -58,7 +59,6 @@ export {
StairTopLandingMode, StairTopLandingMode,
StairType, StairType,
} from './nodes/stair' } from './nodes/stair'
export type { StairSurfaceMaterialRole, StairSurfaceMaterialSpec } from './nodes/stair'
export { AttachmentSide, StairSegmentNode, StairSegmentType } from './nodes/stair-segment' export { AttachmentSide, StairSegmentNode, StairSegmentType } from './nodes/stair-segment'
export { SurfaceHoleMetadata } from './nodes/surface-hole-metadata' export { SurfaceHoleMetadata } from './nodes/surface-hole-metadata'
export type { WallSurfaceMaterialSpec, WallSurfaceSide } from './nodes/wall' export type { WallSurfaceMaterialSpec, WallSurfaceSide } from './nodes/wall'
+14
View File
@@ -2,6 +2,16 @@ import { z } from 'zod'
import { AssetUrl } from '../asset-url' import { AssetUrl } from '../asset-url'
import { BaseNode, nodeType, objectId } from '../base' import { BaseNode, nodeType, objectId } from '../base'
export const GuideScaleReference = z.object({
start: z.tuple([z.number(), z.number()]),
end: z.tuple([z.number(), z.number()]),
realLengthMeters: z.number().positive(),
measuredLengthUnits: z.number().positive(),
metersPerUnit: z.number().positive(),
label: z.string(),
visible: z.boolean().default(true),
})
export const GuideNode = BaseNode.extend({ export const GuideNode = BaseNode.extend({
id: objectId('guide'), id: objectId('guide'),
type: nodeType('guide'), type: nodeType('guide'),
@@ -10,6 +20,10 @@ export const GuideNode = BaseNode.extend({
rotation: 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.number().default(1), scale: z.number().default(1),
opacity: z.number().min(0).max(100).default(50), opacity: z.number().min(0).max(100).default(50),
locked: z.boolean().default(false),
showIn3d: z.boolean().default(false),
scaleReference: GuideScaleReference.nullable().default(null),
}) })
export type GuideScaleReference = z.infer<typeof GuideScaleReference>
export type GuideNode = z.infer<typeof GuideNode> export type GuideNode = z.infer<typeof GuideNode>
File diff suppressed because it is too large Load Diff
@@ -11,6 +11,7 @@ import { useViewer } from '@pascal-app/viewer'
import { Check, ChevronDown, Plus, Trash2 } from 'lucide-react' import { Check, ChevronDown, Plus, Trash2 } from 'lucide-react'
import { useCallback, useRef, useState } from 'react' import { useCallback, useRef, useState } from 'react'
import { useShallow } from 'zustand/react/shallow' import { useShallow } from 'zustand/react/shallow'
import { createLocalGuideImage } from '../../../lib/local-guide-image'
import { cn } from '../../../lib/utils' import { cn } from '../../../lib/utils'
import useEditor, { type GridSnapStep } from '../../../store/use-editor' import useEditor, { type GridSnapStep } from '../../../store/use-editor'
import { useUploadStore } from '../../../store/use-upload' import { useUploadStore } from '../../../store/use-upload'
@@ -61,35 +62,67 @@ function useLevelScans(): ScanNode[] {
// ── Shared upload button for dropdowns ────────────────────────────────────── // ── Shared upload button for dropdowns ──────────────────────────────────────
function UploadButton() { function UploadButton({ onError }: { onError: (message: string | null) => void }) {
const fileInputRef = useRef<HTMLInputElement>(null) const fileInputRef = useRef<HTMLInputElement>(null)
const levelId = useViewer((s) => s.selection.levelId) const levelId = useViewer((s) => s.selection.levelId)
const setSelection = useViewer((s) => s.setSelection)
const setShowGuides = useViewer((s) => s.setShowGuides)
const createNode = useScene((s) => s.createNode)
const setSelectedReferenceId = useEditor((s) => s.setSelectedReferenceId)
const [isAddingGuide, setIsAddingGuide] = useState(false)
const handleFileChange = useCallback( const handleFileChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => { async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0] const file = e.target.files?.[0]
if (!(file && levelId)) return if (!(file && levelId)) return
e.target.value = '' e.target.value = ''
const { uploadHandler } = useUploadStore.getState() onError(null)
if (!uploadHandler) return
if (file.size > MAX_FILE_SIZE) return if (file.size > MAX_FILE_SIZE) {
onError('File is too large. Maximum size is 200 MB.')
return
}
const isScan = const isScan =
file.name.toLowerCase().endsWith('.glb') || file.name.toLowerCase().endsWith('.gltf') file.name.toLowerCase().endsWith('.glb') || file.name.toLowerCase().endsWith('.gltf')
const isImage = file.type.startsWith('image/') const isImage = file.type.startsWith('image/')
if (!(isScan || isImage)) return if (!(isScan || isImage)) {
onError('Upload a .glb/.gltf scan or an image.')
return
}
const type = isScan ? 'scan' : 'guide' if (isImage) {
setIsAddingGuide(true)
try {
const guide = await createLocalGuideImage({ createNode, file, levelId })
setShowGuides(true)
setSelectedReferenceId(guide.id)
setSelection({ selectedIds: [], zoneId: null })
} catch {
onError('Could not add that guide image.')
} finally {
setIsAddingGuide(false)
}
return
}
const { uploadHandler } = useUploadStore.getState()
if (!uploadHandler) {
onError('Scan upload is unavailable.')
return
}
const projectId = window.location.pathname.split('/editor/')[1]?.split('/')[0] const projectId = window.location.pathname.split('/editor/')[1]?.split('/')[0]
if (!projectId) return if (!projectId) {
onError('Open a project before uploading a scan.')
return
}
useUploadStore.getState().clearUpload(levelId) useUploadStore.getState().clearUpload(levelId)
uploadHandler(projectId, levelId, file, type) uploadHandler(projectId, levelId, file, 'scan')
}, },
[levelId], [createNode, levelId, onError, setSelectedReferenceId, setSelection, setShowGuides],
) )
return ( return (
@@ -97,6 +130,7 @@ function UploadButton() {
<button <button
aria-label="Upload scan or guide image" aria-label="Upload scan or guide image"
className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full border border-border/40 text-muted-foreground transition-colors hover:bg-white/10 hover:text-foreground" className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full border border-border/40 text-muted-foreground transition-colors hover:bg-white/10 hover:text-foreground"
disabled={isAddingGuide}
onClick={() => fileInputRef.current?.click()} onClick={() => fileInputRef.current?.click()}
type="button" type="button"
> >
@@ -118,9 +152,13 @@ function UploadButton() {
function GuidesControl() { function GuidesControl() {
const showGuides = useViewer((state) => state.showGuides) const showGuides = useViewer((state) => state.showGuides)
const setShowGuides = useViewer((state) => state.setShowGuides) const setShowGuides = useViewer((state) => state.setShowGuides)
const setSelection = useViewer((state) => state.setSelection)
const updateNode = useScene((state) => state.updateNode) const updateNode = useScene((state) => state.updateNode)
const deleteNode = useScene((state) => state.deleteNode) const deleteNode = useScene((state) => state.deleteNode)
const selectedReferenceId = useEditor((state) => state.selectedReferenceId)
const setSelectedReferenceId = useEditor((state) => state.setSelectedReferenceId)
const [isOpen, setIsOpen] = useState(false) const [isOpen, setIsOpen] = useState(false)
const [uploadError, setUploadError] = useState<string | null>(null)
const guides = useLevelGuides() const guides = useLevelGuides()
const hasGuides = guides.length > 0 const hasGuides = guides.length > 0
@@ -132,6 +170,15 @@ function GuidesControl() {
[updateNode], [updateNode],
) )
const handleSelectGuide = useCallback(
(guideId: GuideNode['id']) => {
setShowGuides(true)
setSelectedReferenceId(guideId)
setSelection({ selectedIds: [], zoneId: null })
},
[setSelectedReferenceId, setSelection, setShowGuides],
)
return ( return (
<Popover onOpenChange={setIsOpen} open={isOpen}> <Popover onOpenChange={setIsOpen} open={isOpen}>
<div className="flex items-center"> <div className="flex items-center">
@@ -201,29 +248,55 @@ function GuidesControl() {
</p> </p>
)} )}
</div> </div>
<UploadButton /> <UploadButton onError={setUploadError} />
</div> </div>
{uploadError && (
<div className="rounded-lg border border-destructive/30 bg-destructive/10 px-2.5 py-2 text-destructive text-xs">
{uploadError}
</div>
)}
{hasGuides ? ( {hasGuides ? (
<div className="max-h-56 space-y-2 overflow-y-auto pr-1"> <div className="max-h-56 space-y-2 overflow-y-auto pr-1">
{guides.map((guide, index) => ( {guides.map((guide, index) => (
<div <div
className="group/item space-y-2 rounded-xl border border-border/45 bg-background/75 p-2.5" className={cn(
'group/item space-y-2 rounded-xl border bg-background/75 p-2.5 transition-colors',
selectedReferenceId === guide.id
? 'border-foreground/35 bg-white/10'
: 'border-border/45',
)}
key={guide.id} key={guide.id}
> >
<div className="flex min-w-0 items-center gap-2"> <div className="flex min-w-0 items-center gap-2">
<img <button
alt="" className="flex min-w-0 flex-1 items-center gap-2 text-left"
className="h-3.5 w-3.5 shrink-0 object-contain opacity-70" onClick={() => handleSelectGuide(guide.id)}
src="/icons/floorplan.png" type="button"
/> >
<p className="truncate font-medium text-foreground text-sm"> <img
{guide.name || `Guide image ${index + 1}`} alt=""
</p> className="h-3.5 w-3.5 shrink-0 object-contain opacity-70"
src="/icons/floorplan.png"
/>
<p className="truncate font-medium text-foreground text-sm">
{guide.name || `Guide image ${index + 1}`}
</p>
{selectedReferenceId === guide.id && (
<Check className="ml-auto h-3.5 w-3.5 shrink-0 text-foreground/80" />
)}
</button>
<button <button
aria-label="Delete guide image" aria-label="Delete guide image"
className="ml-auto flex h-5 w-5 shrink-0 items-center justify-center rounded-md text-muted-foreground/50 opacity-0 transition-all hover:bg-destructive/10 hover:text-destructive group-hover/item:opacity-100" className="flex h-5 w-5 shrink-0 items-center justify-center rounded-md text-muted-foreground/50 opacity-0 transition-all hover:bg-destructive/10 hover:text-destructive group-hover/item:opacity-100"
onClick={() => deleteNode(guide.id)} onClick={(event) => {
event.stopPropagation()
deleteNode(guide.id)
if (selectedReferenceId === guide.id) {
setSelectedReferenceId(null)
}
}}
type="button" type="button"
> >
<Trash2 className="h-3 w-3" /> <Trash2 className="h-3 w-3" />
@@ -335,9 +408,13 @@ export function GridSnapControl() {
function ScansControl() { function ScansControl() {
const showScans = useViewer((state) => state.showScans) const showScans = useViewer((state) => state.showScans)
const setShowScans = useViewer((state) => state.setShowScans) const setShowScans = useViewer((state) => state.setShowScans)
const setSelection = useViewer((state) => state.setSelection)
const updateNode = useScene((state) => state.updateNode) const updateNode = useScene((state) => state.updateNode)
const deleteNode = useScene((state) => state.deleteNode) const deleteNode = useScene((state) => state.deleteNode)
const selectedReferenceId = useEditor((state) => state.selectedReferenceId)
const setSelectedReferenceId = useEditor((state) => state.setSelectedReferenceId)
const [isOpen, setIsOpen] = useState(false) const [isOpen, setIsOpen] = useState(false)
const [uploadError, setUploadError] = useState<string | null>(null)
const scans = useLevelScans() const scans = useLevelScans()
const hasScans = scans.length > 0 const hasScans = scans.length > 0
@@ -349,6 +426,15 @@ function ScansControl() {
[updateNode], [updateNode],
) )
const handleSelectScan = useCallback(
(scanId: ScanNode['id']) => {
setShowScans(true)
setSelectedReferenceId(scanId)
setSelection({ selectedIds: [], zoneId: null })
},
[setSelectedReferenceId, setSelection, setShowScans],
)
return ( return (
<Popover onOpenChange={setIsOpen} open={isOpen}> <Popover onOpenChange={setIsOpen} open={isOpen}>
<div className="flex items-center"> <div className="flex items-center">
@@ -414,29 +500,55 @@ function ScansControl() {
</p> </p>
)} )}
</div> </div>
<UploadButton /> <UploadButton onError={setUploadError} />
</div> </div>
{uploadError && (
<div className="rounded-lg border border-destructive/30 bg-destructive/10 px-2.5 py-2 text-destructive text-xs">
{uploadError}
</div>
)}
{hasScans ? ( {hasScans ? (
<div className="max-h-56 space-y-2 overflow-y-auto pr-1"> <div className="max-h-56 space-y-2 overflow-y-auto pr-1">
{scans.map((scan, index) => ( {scans.map((scan, index) => (
<div <div
className="group/item space-y-2 rounded-xl border border-border/45 bg-background/75 p-2.5" className={cn(
'group/item space-y-2 rounded-xl border bg-background/75 p-2.5 transition-colors',
selectedReferenceId === scan.id
? 'border-foreground/35 bg-white/10'
: 'border-border/45',
)}
key={scan.id} key={scan.id}
> >
<div className="flex min-w-0 items-center gap-2"> <div className="flex min-w-0 items-center gap-2">
<img <button
alt="" className="flex min-w-0 flex-1 items-center gap-2 text-left"
className="h-3.5 w-3.5 shrink-0 object-contain opacity-70" onClick={() => handleSelectScan(scan.id)}
src="/icons/mesh.png" type="button"
/> >
<p className="truncate font-medium text-foreground text-sm"> <img
{scan.name || `Scan ${index + 1}`} alt=""
</p> className="h-3.5 w-3.5 shrink-0 object-contain opacity-70"
src="/icons/mesh.png"
/>
<p className="truncate font-medium text-foreground text-sm">
{scan.name || `Scan ${index + 1}`}
</p>
{selectedReferenceId === scan.id && (
<Check className="ml-auto h-3.5 w-3.5 shrink-0 text-foreground/80" />
)}
</button>
<button <button
aria-label="Delete scan" aria-label="Delete scan"
className="ml-auto flex h-5 w-5 shrink-0 items-center justify-center rounded-md text-muted-foreground/50 opacity-0 transition-all hover:bg-destructive/10 hover:text-destructive group-hover/item:opacity-100" className="flex h-5 w-5 shrink-0 items-center justify-center rounded-md text-muted-foreground/50 opacity-0 transition-all hover:bg-destructive/10 hover:text-destructive group-hover/item:opacity-100"
onClick={() => deleteNode(scan.id)} onClick={(event) => {
event.stopPropagation()
deleteNode(scan.id)
if (selectedReferenceId === scan.id) {
setSelectedReferenceId(null)
}
}}
type="button" type="button"
> >
<Trash2 className="h-3 w-3" /> <Trash2 className="h-3 w-3" />
@@ -1,21 +1,43 @@
'use client' 'use client'
import { type AnyNode, type GuideNode, type ScanNode, useScene } from '@pascal-app/core' import {
import { Box, Image as ImageIcon } from 'lucide-react' type AnyNode,
import { useCallback } from 'react' emitter,
type GuideNode,
loadAssetUrl,
saveAsset,
type ScanNode,
useScene,
} from '@pascal-app/core'
import { Eye, EyeOff, LocateFixed, Lock, RotateCcw, Ruler, Trash2, Unlock, Upload } from 'lucide-react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { getGuideImageName } from '../../../lib/local-guide-image'
import useEditor from '../../../store/use-editor' import useEditor from '../../../store/use-editor'
import { ActionButton, ActionGroup } from '../controls/action-button' import { ActionButton, ActionGroup } from '../controls/action-button'
import { MetricControl } from '../controls/metric-control'
import { PanelSection } from '../controls/panel-section' import { PanelSection } from '../controls/panel-section'
import { SliderControl } from '../controls/slider-control' import { SliderControl } from '../controls/slider-control'
import { PanelWrapper } from './panel-wrapper' import { PanelWrapper } from './panel-wrapper'
type ReferenceNode = ScanNode | GuideNode type ReferenceNode = ScanNode | GuideNode
function getScaleStatus(guide: GuideNode) {
const reference = guide.scaleReference
if (!reference) {
return 'Uncalibrated'
}
return `${reference.visible === false ? 'Scaled (hidden)' : 'Scaled'} · ${reference.label}`
}
export function ReferencePanel() { export function ReferencePanel() {
const selectedReferenceId = useEditor((s) => s.selectedReferenceId) const selectedReferenceId = useEditor((s) => s.selectedReferenceId)
const setSelectedReferenceId = useEditor((s) => s.setSelectedReferenceId) const setSelectedReferenceId = useEditor((s) => s.setSelectedReferenceId)
const updateNode = useScene((s) => s.updateNode) const updateNode = useScene((s) => s.updateNode)
const deleteNode = useScene((s) => s.deleteNode)
const replaceInputRef = useRef<HTMLInputElement>(null)
const [isReplacing, setIsReplacing] = useState(false)
const [replaceError, setReplaceError] = useState<string | null>(null)
const [isAssetMissing, setIsAssetMissing] = useState(false)
const node = useScene((s) => const node = useScene((s) =>
selectedReferenceId selectedReferenceId
@@ -35,17 +57,225 @@ export function ReferencePanel() {
setSelectedReferenceId(null) setSelectedReferenceId(null)
}, [setSelectedReferenceId]) }, [setSelectedReferenceId])
const handleReplaceFile = useCallback(
async (file: File) => {
if (!(selectedReferenceId && node?.type === 'guide')) {
return
}
if (!file.type.startsWith('image/')) {
setReplaceError('Choose a PNG, JPEG, or WebP image.')
return
}
setIsReplacing(true)
setReplaceError(null)
try {
const assetUrl = await saveAsset(file)
updateNode(selectedReferenceId as AnyNode['id'], {
name: getGuideImageName(file.name),
url: assetUrl,
scaleReference: null,
} as Partial<GuideNode>)
} catch {
setReplaceError('Could not replace that image.')
} finally {
setIsReplacing(false)
}
},
[node?.type, selectedReferenceId, updateNode],
)
const handleDeleteGuide = useCallback(() => {
if (!(selectedReferenceId && node?.type === 'guide')) {
return
}
deleteNode(selectedReferenceId as AnyNode['id'])
emitter.emit('guide:deleted', { guideId: selectedReferenceId as GuideNode['id'] })
setSelectedReferenceId(null)
}, [deleteNode, node?.type, selectedReferenceId, setSelectedReferenceId])
const handleStartScale = useCallback(() => {
if (node?.type !== 'guide') {
return
}
emitter.emit('guide:set-reference-scale', { guideId: node.id })
}, [node])
const handleCancelScale = useCallback(() => {
emitter.emit('guide:cancel-reference-scale')
}, [])
useEffect(() => {
if (node?.type !== 'guide' || !node.url.startsWith('asset://')) {
setIsAssetMissing(false)
return
}
let cancelled = false
loadAssetUrl(node.url).then((resolvedUrl) => {
if (!cancelled) {
setIsAssetMissing(!resolvedUrl)
}
})
return () => {
cancelled = true
}
}, [node])
if (!node || (node.type !== 'scan' && node.type !== 'guide')) return null if (!node || (node.type !== 'scan' && node.type !== 'guide')) return null
const isScan = node.type === 'scan' const isScan = node.type === 'scan'
const scaleStatus = !isScan ? getScaleStatus(node) : null
return ( return (
<PanelWrapper <PanelWrapper
icon={isScan ? undefined : undefined}
onClose={handleClose} onClose={handleClose}
title={node.name || (isScan ? '3D Scan' : 'Guide Image')} title={node.name || (isScan ? '3D Scan' : 'Guide Image')}
width={300} width={300}
> >
{!isScan && (
<>
<PanelSection title="Image">
<input
accept="image/*"
className="hidden"
onChange={(event) => {
const file = event.currentTarget.files?.[0]
event.currentTarget.value = ''
if (file) {
void handleReplaceFile(file)
}
}}
ref={replaceInputRef}
type="file"
/>
<ActionGroup>
<ActionButton
icon={<Upload className="h-3.5 w-3.5" />}
label={isReplacing ? 'Replacing...' : 'Replace'}
onClick={() => replaceInputRef.current?.click()}
disabled={isReplacing}
/>
<ActionButton
icon={<Trash2 className="h-3.5 w-3.5" />}
label="Delete"
onClick={handleDeleteGuide}
className="text-destructive hover:bg-destructive/10"
/>
</ActionGroup>
<ActionGroup>
<ActionButton
icon={
node.visible === false ? (
<EyeOff className="h-3.5 w-3.5" />
) : (
<Eye className="h-3.5 w-3.5" />
)
}
label={node.visible === false ? 'Show' : 'Hide'}
onClick={() => handleUpdate({ visible: node.visible === false })}
/>
<ActionButton
icon={
node.locked ? (
<Lock className="h-3.5 w-3.5" />
) : (
<Unlock className="h-3.5 w-3.5" />
)
}
label={node.locked ? 'Unlock' : 'Lock'}
onClick={() => handleUpdate({ locked: node.locked !== true } as Partial<GuideNode>)}
/>
</ActionGroup>
{replaceError && (
<div className="rounded-md border border-destructive/30 bg-destructive/10 px-2 py-1.5 text-destructive text-xs">
{replaceError}
</div>
)}
{isAssetMissing && (
<div className="rounded-md border border-amber-500/35 bg-amber-500/10 px-2 py-1.5 text-amber-700 text-xs dark:text-amber-300">
Overlay image unavailable. Replace the image to restore it.
</div>
)}
</PanelSection>
<PanelSection title="Reference Scale">
<div className="flex items-center gap-2 rounded-md border border-border/50 bg-background/40 px-2.5 py-2 text-sm">
<Ruler className="h-4 w-4 shrink-0 text-primary" />
<span className="truncate text-muted-foreground">{scaleStatus}</span>
</div>
<ActionGroup>
<ActionButton
label={node.scaleReference ? 'Edit Scale' : 'Set Scale'}
onClick={handleStartScale}
/>
<ActionButton label="Cancel" onClick={handleCancelScale} />
</ActionGroup>
<ActionGroup>
<ActionButton
label={node.scaleReference?.visible === false ? 'Show Scale' : 'Hide Scale'}
disabled={!node.scaleReference}
onClick={() => {
if (!node.scaleReference) return
handleUpdate({
scaleReference: {
...node.scaleReference,
visible: node.scaleReference.visible === false,
},
} as Partial<GuideNode>)
}}
/>
<ActionButton
label="Clear Scale"
disabled={!node.scaleReference}
onClick={() => handleUpdate({ scaleReference: null } as Partial<GuideNode>)}
/>
</ActionGroup>
</PanelSection>
<PanelSection title="Quick Actions">
<ActionGroup>
<ActionButton
icon={<LocateFixed className="h-3.5 w-3.5" />}
label="Center"
onClick={() =>
handleUpdate({
position: [0, node.position[1], 0],
} as Partial<GuideNode>)
}
/>
<ActionButton
icon={<RotateCcw className="h-3.5 w-3.5" />}
label="Reset Rotation"
onClick={() =>
handleUpdate({
rotation: [node.rotation[0], 0, node.rotation[2]],
} as Partial<GuideNode>)
}
/>
</ActionGroup>
<ActionGroup>
<ActionButton
icon={<Ruler className="h-3.5 w-3.5" />}
label="Reset Image Scale"
onClick={() => handleUpdate({ scale: 1 } as Partial<GuideNode>)}
/>
</ActionGroup>
</PanelSection>
</>
)}
<PanelSection title="Position"> <PanelSection title="Position">
<SliderControl <SliderControl
label={ label={
@@ -32,6 +32,7 @@ import {
PopoverTrigger, PopoverTrigger,
} from './../../../../../components/ui/primitives/popover' } from './../../../../../components/ui/primitives/popover'
import { deleteLevelWithFallbackSelection } from './../../../../../lib/level-selection' import { deleteLevelWithFallbackSelection } from './../../../../../lib/level-selection'
import { createLocalGuideImage } from './../../../../../lib/local-guide-image'
import { cn } from './../../../../../lib/utils' import { cn } from './../../../../../lib/utils'
import useEditor from './../../../../../store/use-editor' import useEditor from './../../../../../store/use-editor'
import { useUploadStore } from '../../../../../store/use-upload' import { useUploadStore } from '../../../../../store/use-upload'
@@ -394,7 +395,10 @@ const LevelReferences = memo(function LevelReferences({
onUploadAsset, onUploadAsset,
onDeleteAsset, onDeleteAsset,
}: LevelReferencesProps) { }: LevelReferencesProps) {
const createNode = useScene((s) => s.createNode)
const deleteNode = useScene((s) => s.deleteNode) const deleteNode = useScene((s) => s.deleteNode)
const setSelection = useViewer((s) => s.setSelection)
const setShowGuides = useViewer((s) => s.setShowGuides)
const references = useScene( const references = useScene(
useShallow((s) => useShallow((s) =>
Object.values(s.nodes).filter( Object.values(s.nodes).filter(
@@ -417,19 +421,27 @@ const LevelReferences = memo(function LevelReferences({
const scanInputRef = useRef<HTMLInputElement>(null) const scanInputRef = useRef<HTMLInputElement>(null)
const handleAddAsset = (e: React.ChangeEvent<HTMLInputElement>) => { const handleAddAsset = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0] const file = e.target.files?.[0]
if (!file) return if (!file) return
e.target.value = '' e.target.value = ''
if (!projectId) { // Auto-detect type based on file extension/mime type
useUploadStore.getState().startUpload(levelId, 'scan', file.name) const isScan =
useUploadStore.getState().setError(levelId, 'No active project. Please open a project first.') file.name.toLowerCase().endsWith('.glb') || file.name.toLowerCase().endsWith('.gltf')
const isImage = file.type.startsWith('image/')
const type = isScan ? 'scan' : 'guide'
if (!(isScan || isImage)) {
useUploadStore.getState().startUpload(levelId, type, file.name)
useUploadStore
.getState()
.setError(levelId, 'Invalid file type. Please upload a .glb/.gltf scan or an image.')
return return
} }
if (file.size > MAX_FILE_SIZE) { if (file.size > MAX_FILE_SIZE) {
useUploadStore.getState().startUpload(levelId, 'scan', file.name) useUploadStore.getState().startUpload(levelId, type, file.name)
useUploadStore useUploadStore
.getState() .getState()
.setError( .setError(
@@ -439,20 +451,28 @@ const LevelReferences = memo(function LevelReferences({
return return
} }
// Auto-detect type based on file extension/mime type if (isImage) {
const isScan = useUploadStore.getState().startUpload(levelId, 'guide', file.name)
file.name.toLowerCase().endsWith('.glb') || file.name.toLowerCase().endsWith('.gltf') useUploadStore.getState().setStatus(levelId, 'uploading')
const isImage = file.type.startsWith('image/')
if (!(isScan || isImage)) { try {
useUploadStore.getState().startUpload(levelId, 'scan', file.name) const guide = await createLocalGuideImage({ createNode, file, levelId })
useUploadStore setShowGuides(true)
.getState() setSelectedReferenceId(guide.id)
.setError(levelId, 'Invalid file type. Please upload a .glb/.gltf scan or an image.') setSelection({ selectedIds: [], zoneId: null })
useUploadStore.getState().setResult(levelId, guide.url)
window.setTimeout(() => useUploadStore.getState().clearUpload(levelId), 600)
} catch {
useUploadStore.getState().setError(levelId, 'Could not add that guide image.')
}
return return
} }
const type = isScan ? 'scan' : 'guide' if (!projectId) {
useUploadStore.getState().startUpload(levelId, 'scan', file.name)
useUploadStore.getState().setError(levelId, 'No active project. Please open a project first.')
return
}
clearUpload(levelId) clearUpload(levelId)
onUploadAsset?.(projectId, levelId, file, type) onUploadAsset?.(projectId, levelId, file, type)
@@ -0,0 +1,44 @@
import {
type AnyNodeId,
GuideNode,
type GuideNode as GuideNodeType,
saveAsset,
} from '@pascal-app/core'
export function getGuideImageName(filename: string) {
const trimmed = filename.trim()
if (!trimmed) {
return 'Guide image'
}
const dotIndex = trimmed.lastIndexOf('.')
return dotIndex > 0 ? trimmed.slice(0, dotIndex) : trimmed
}
export async function createLocalGuideImage({
createNode,
file,
levelId,
position = [0, 0, 0],
}: {
createNode: (node: GuideNodeType, parentId: AnyNodeId) => void
file: File
levelId: string
position?: [number, number, number]
}) {
const assetUrl = await saveAsset(file)
const guide = GuideNode.parse({
name: getGuideImageName(file.name),
url: assetUrl,
position,
rotation: [0, 0, 0],
scale: 1,
opacity: 50,
locked: false,
showIn3d: false,
scaleReference: null,
})
createNode(guide, levelId as AnyNodeId)
return guide
}
@@ -19,7 +19,7 @@ export const GuideRenderer = ({ node }: { node: GuideNode }) => {
position={node.position} position={node.position}
ref={ref} ref={ref}
rotation={[0, node.rotation[1], 0]} rotation={[0, node.rotation[1], 0]}
visible={showGuides} visible={showGuides && node.showIn3d === true}
> >
{resolvedUrl && ( {resolvedUrl && (
<Suspense> <Suspense>