Refactor editor workflow and update UI state handling
This commit is contained in:
@@ -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 &
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user