scans and ref image
This commit is contained in:
@@ -13,6 +13,7 @@ import { useKeyboard } from '@/hooks/use-keyboard'
|
|||||||
import { ZoneSystem } from '../systems/zone/zone-system'
|
import { ZoneSystem } from '../systems/zone/zone-system'
|
||||||
import { ToolManager } from '../tools/tool-manager'
|
import { ToolManager } from '../tools/tool-manager'
|
||||||
import { ActionMenu } from '../ui/action-menu'
|
import { ActionMenu } from '../ui/action-menu'
|
||||||
|
import { ReferencePanel } from '../ui/panels/reference-panel'
|
||||||
import { SidebarProvider } from '../ui/primitives/sidebar'
|
import { SidebarProvider } from '../ui/primitives/sidebar'
|
||||||
import { AppSidebar } from '../ui/sidebar/app-sidebar'
|
import { AppSidebar } from '../ui/sidebar/app-sidebar'
|
||||||
import { CustomCameraControls } from './custom-camera-controls'
|
import { CustomCameraControls } from './custom-camera-controls'
|
||||||
@@ -31,6 +32,7 @@ export default function Editor() {
|
|||||||
|
|
||||||
<TestUndo />
|
<TestUndo />
|
||||||
<ActionMenu />
|
<ActionMenu />
|
||||||
|
<ReferencePanel />
|
||||||
|
|
||||||
<SidebarProvider className="fixed z-10">
|
<SidebarProvider className="fixed z-10">
|
||||||
<AppSidebar />
|
<AppSidebar />
|
||||||
|
|||||||
@@ -0,0 +1,176 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { type AnyNode, type GuideNode, type ScanNode, useScene } from '@pascal-app/core'
|
||||||
|
import { Box, Image, X } from 'lucide-react'
|
||||||
|
import { useCallback } from 'react'
|
||||||
|
import useEditor from '@/store/use-editor'
|
||||||
|
|
||||||
|
type ReferenceNode = ScanNode | GuideNode
|
||||||
|
|
||||||
|
export function ReferencePanel() {
|
||||||
|
const selectedReferenceId = useEditor((s) => s.selectedReferenceId)
|
||||||
|
const setSelectedReferenceId = useEditor((s) => s.setSelectedReferenceId)
|
||||||
|
const nodes = useScene((s) => s.nodes)
|
||||||
|
const updateNode = useScene((s) => s.updateNode)
|
||||||
|
|
||||||
|
const node = selectedReferenceId
|
||||||
|
? (nodes[selectedReferenceId as AnyNode['id']] as ReferenceNode | undefined)
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
const handleUpdate = useCallback(
|
||||||
|
(updates: Partial<ReferenceNode>) => {
|
||||||
|
if (!selectedReferenceId) return
|
||||||
|
updateNode(selectedReferenceId as AnyNode['id'], updates)
|
||||||
|
},
|
||||||
|
[selectedReferenceId, updateNode],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleClose = useCallback(() => {
|
||||||
|
setSelectedReferenceId(null)
|
||||||
|
}, [setSelectedReferenceId])
|
||||||
|
|
||||||
|
if (!node || (node.type !== 'scan' && node.type !== 'guide')) return null
|
||||||
|
|
||||||
|
const isScan = node.type === 'scan'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pointer-events-auto fixed top-20 right-4 z-50 flex w-72 flex-col overflow-hidden rounded-lg border border-border bg-background/95 shadow-xl backdrop-blur-md">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between border-b p-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{isScan ? (
|
||||||
|
<Box className="h-4 w-4 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<Image className="h-4 w-4 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
<h2 className="font-semibold text-foreground text-sm">
|
||||||
|
{node.name || (isScan ? '3D Scan' : 'Guide Image')}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="rounded p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground cursor-pointer"
|
||||||
|
onClick={handleClose}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-3">
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Position */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
|
||||||
|
Position
|
||||||
|
</label>
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
{([0, 1, 2] as const).map((i) => (
|
||||||
|
<div key={i} className="space-y-1">
|
||||||
|
<label className="text-muted-foreground text-xs">{['X', 'Y', 'Z'][i]}</label>
|
||||||
|
<input
|
||||||
|
className="w-full rounded border border-input bg-background px-2 py-1 text-foreground text-sm outline-none focus:border-primary"
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = Number.parseFloat(e.target.value)
|
||||||
|
if (!Number.isNaN(value)) {
|
||||||
|
const pos = [...node.position] as [number, number, number]
|
||||||
|
pos[i] = value
|
||||||
|
handleUpdate({ position: pos })
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
step="0.1"
|
||||||
|
type="number"
|
||||||
|
value={Math.round(node.position[i] * 100) / 100}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Rotation Y */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
|
||||||
|
Rotation
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<input
|
||||||
|
className="min-w-0 flex-1 rounded border border-input bg-background px-2 py-1 text-foreground text-sm outline-none focus:border-primary"
|
||||||
|
onChange={(e) => {
|
||||||
|
const degrees = Number.parseFloat(e.target.value)
|
||||||
|
if (!Number.isNaN(degrees)) {
|
||||||
|
const radians = (degrees * Math.PI) / 180
|
||||||
|
handleUpdate({
|
||||||
|
rotation: [node.rotation[0], radians, node.rotation[2]],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
step="1"
|
||||||
|
type="number"
|
||||||
|
value={Math.round((node.rotation[1] * 180) / Math.PI)}
|
||||||
|
/>
|
||||||
|
<span className="text-muted-foreground text-xs shrink-0">°</span>
|
||||||
|
<button
|
||||||
|
className="shrink-0 rounded border border-border px-1.5 py-0.5 text-xs hover:bg-accent cursor-pointer"
|
||||||
|
onClick={() =>
|
||||||
|
handleUpdate({
|
||||||
|
rotation: [node.rotation[0], node.rotation[1] - Math.PI / 4, node.rotation[2]],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
−45
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="shrink-0 rounded border border-border px-1.5 py-0.5 text-xs hover:bg-accent cursor-pointer"
|
||||||
|
onClick={() =>
|
||||||
|
handleUpdate({
|
||||||
|
rotation: [node.rotation[0], node.rotation[1] + Math.PI / 4, node.rotation[2]],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
+45
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Scale */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
|
||||||
|
Scale
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
className="w-full rounded border border-input bg-background px-2 py-1 text-foreground text-sm outline-none focus:border-primary"
|
||||||
|
min="0.01"
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = Number.parseFloat(e.target.value)
|
||||||
|
if (!Number.isNaN(value) && value > 0) {
|
||||||
|
handleUpdate({ scale: value })
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
step="0.1"
|
||||||
|
type="number"
|
||||||
|
value={Math.round(node.scale * 100) / 100}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Opacity */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<label className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
|
||||||
|
Opacity
|
||||||
|
</label>
|
||||||
|
<span className="text-muted-foreground text-xs">{node.opacity}%</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
className="w-full cursor-pointer"
|
||||||
|
max="100"
|
||||||
|
min="0"
|
||||||
|
onChange={(e) => handleUpdate({ opacity: Number.parseInt(e.target.value, 10) })}
|
||||||
|
step="1"
|
||||||
|
type="range"
|
||||||
|
value={node.opacity}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -10,12 +10,15 @@ import {
|
|||||||
ChevronDown,
|
ChevronDown,
|
||||||
Hexagon,
|
Hexagon,
|
||||||
Layers,
|
Layers,
|
||||||
|
MoreHorizontal,
|
||||||
Plus,
|
Plus,
|
||||||
Trash2,
|
Trash2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import useEditor from "@/store/use-editor";
|
import useEditor from "@/store/use-editor";
|
||||||
import { TreeNode } from "./tree-node";
|
import { TreeNode } from "./tree-node";
|
||||||
|
import { ReferencesDialog } from "./references-dialog";
|
||||||
import {
|
import {
|
||||||
Popover,
|
Popover,
|
||||||
PopoverContent,
|
PopoverContent,
|
||||||
@@ -156,6 +159,8 @@ function LevelsSection() {
|
|||||||
const selectedLevelId = useViewer((state) => state.selection.levelId);
|
const selectedLevelId = useViewer((state) => state.selection.levelId);
|
||||||
const setSelection = useViewer((state) => state.setSelection);
|
const setSelection = useViewer((state) => state.setSelection);
|
||||||
|
|
||||||
|
const [referencesLevelId, setReferencesLevelId] = useState<string | null>(null);
|
||||||
|
|
||||||
const building = selectedBuildingId
|
const building = selectedBuildingId
|
||||||
? (nodes[selectedBuildingId] as BuildingNode)
|
? (nodes[selectedBuildingId] as BuildingNode)
|
||||||
: null;
|
: null;
|
||||||
@@ -194,19 +199,46 @@ function LevelsSection() {
|
|||||||
{/* Level buttons */}
|
{/* Level buttons */}
|
||||||
<div className="flex flex-col gap-0.5 px-2 pb-2">
|
<div className="flex flex-col gap-0.5 px-2 pb-2">
|
||||||
{levels.map((level) => (
|
{levels.map((level) => (
|
||||||
<button
|
<div
|
||||||
key={level.id}
|
key={level.id}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-2 px-2 py-1.5 rounded text-sm transition-colors cursor-pointer",
|
"flex items-center group/level rounded transition-colors",
|
||||||
selectedLevelId === level.id
|
selectedLevelId === level.id
|
||||||
? "bg-primary text-primary-foreground"
|
? "bg-primary text-primary-foreground"
|
||||||
: "hover:bg-accent/50 text-foreground"
|
: "hover:bg-accent/50 text-foreground"
|
||||||
)}
|
)}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
className="flex-1 flex items-center gap-2 px-2 py-1.5 text-sm cursor-pointer min-w-0"
|
||||||
onClick={() => setSelection({ levelId: level.id })}
|
onClick={() => setSelection({ levelId: level.id })}
|
||||||
>
|
>
|
||||||
<Layers className="w-3.5 h-3.5 shrink-0" />
|
<Layers className="w-3.5 h-3.5 shrink-0" />
|
||||||
<span className="truncate">{level.name || `Level ${level.level}`}</span>
|
<span className="truncate">{level.name || `Level ${level.level}`}</span>
|
||||||
</button>
|
</button>
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<button
|
||||||
|
className={cn(
|
||||||
|
"opacity-0 group-hover/level:opacity-100 w-6 h-6 mr-1 flex items-center justify-center rounded cursor-pointer shrink-0",
|
||||||
|
selectedLevelId === level.id
|
||||||
|
? "hover:bg-primary-foreground/20"
|
||||||
|
: "hover:bg-accent"
|
||||||
|
)}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent align="start" side="right" className="w-40 p-1">
|
||||||
|
<button
|
||||||
|
className="flex items-center gap-2 w-full px-3 py-1.5 rounded text-sm hover:bg-accent cursor-pointer"
|
||||||
|
onClick={() => setReferencesLevelId(level.id)}
|
||||||
|
>
|
||||||
|
References
|
||||||
|
</button>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
{levels.length === 0 && (
|
{levels.length === 0 && (
|
||||||
<div className="text-xs text-muted-foreground px-2 py-1">
|
<div className="text-xs text-muted-foreground px-2 py-1">
|
||||||
@@ -214,6 +246,17 @@ function LevelsSection() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* References dialog */}
|
||||||
|
{referencesLevelId && (
|
||||||
|
<ReferencesDialog
|
||||||
|
levelId={referencesLevelId}
|
||||||
|
open={!!referencesLevelId}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setReferencesLevelId(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,186 @@
|
|||||||
|
import {
|
||||||
|
type AnyNodeId,
|
||||||
|
type GuideNode,
|
||||||
|
GuideNode as GuideNodeSchema,
|
||||||
|
type LevelNode,
|
||||||
|
type ScanNode,
|
||||||
|
ScanNode as ScanNodeSchema,
|
||||||
|
saveAsset,
|
||||||
|
useScene,
|
||||||
|
} from '@pascal-app/core'
|
||||||
|
import { Box, Image, Pencil, Plus, Trash2 } from 'lucide-react'
|
||||||
|
import { useCallback, useRef } from 'react'
|
||||||
|
import useEditor from '@/store/use-editor'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/primitives/dialog'
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from '@/components/ui/primitives/popover'
|
||||||
|
|
||||||
|
interface ReferencesDialogProps {
|
||||||
|
levelId: string
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReferencesDialog({ levelId, open, onOpenChange }: ReferencesDialogProps) {
|
||||||
|
const nodes = useScene((s) => s.nodes)
|
||||||
|
const createNode = useScene((s) => s.createNode)
|
||||||
|
const deleteNode = useScene((s) => s.deleteNode)
|
||||||
|
const setSelectedReferenceId = useEditor((s) => s.setSelectedReferenceId)
|
||||||
|
|
||||||
|
const scanInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const guideInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
const level = nodes[levelId as AnyNodeId] as LevelNode | undefined
|
||||||
|
if (!level) return null
|
||||||
|
|
||||||
|
// Find all scan and guide children of this level
|
||||||
|
const references = Object.values(nodes).filter(
|
||||||
|
(node): node is ScanNode | GuideNode =>
|
||||||
|
(node.type === 'scan' || node.type === 'guide') && node.parentId === levelId,
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleAddScan = useCallback(
|
||||||
|
async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
const url = await saveAsset(file)
|
||||||
|
const node = ScanNodeSchema.parse({
|
||||||
|
url,
|
||||||
|
name: file.name,
|
||||||
|
parentId: levelId,
|
||||||
|
})
|
||||||
|
createNode(node, levelId as AnyNodeId)
|
||||||
|
e.target.value = ''
|
||||||
|
},
|
||||||
|
[levelId, createNode],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleAddGuide = useCallback(
|
||||||
|
async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
const url = await saveAsset(file)
|
||||||
|
const node = GuideNodeSchema.parse({
|
||||||
|
url,
|
||||||
|
name: file.name,
|
||||||
|
parentId: levelId,
|
||||||
|
})
|
||||||
|
createNode(node, levelId as AnyNodeId)
|
||||||
|
e.target.value = ''
|
||||||
|
},
|
||||||
|
[levelId, createNode],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleEdit = useCallback(
|
||||||
|
(nodeId: string) => {
|
||||||
|
setSelectedReferenceId(nodeId)
|
||||||
|
onOpenChange(false)
|
||||||
|
},
|
||||||
|
[setSelectedReferenceId, onOpenChange],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleDelete = useCallback(
|
||||||
|
(nodeId: string) => {
|
||||||
|
deleteNode(nodeId as AnyNodeId)
|
||||||
|
},
|
||||||
|
[deleteNode],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>References — {level.name || `Level ${level.level}`}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1 max-h-64 overflow-y-auto">
|
||||||
|
{references.length === 0 && (
|
||||||
|
<p className="text-sm text-muted-foreground py-4 text-center">
|
||||||
|
No references yet. Add a 3D scan or guide image.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{references.map((ref) => (
|
||||||
|
<div
|
||||||
|
key={ref.id}
|
||||||
|
className="flex items-center gap-2 px-2 py-1.5 rounded text-sm hover:bg-accent/50 group"
|
||||||
|
>
|
||||||
|
{ref.type === 'scan' ? (
|
||||||
|
<Box className="w-4 h-4 shrink-0 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<Image className="w-4 h-4 shrink-0 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
<span className="flex-1 truncate">
|
||||||
|
{ref.name || (ref.type === 'scan' ? '3D Scan' : 'Guide Image')}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
className="opacity-0 group-hover:opacity-100 w-6 h-6 flex items-center justify-center rounded hover:bg-accent cursor-pointer"
|
||||||
|
onClick={() => handleEdit(ref.id)}
|
||||||
|
title="Edit"
|
||||||
|
>
|
||||||
|
<Pencil className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="opacity-0 group-hover:opacity-100 w-6 h-6 flex items-center justify-center rounded hover:bg-destructive/10 text-destructive cursor-pointer"
|
||||||
|
onClick={() => handleDelete(ref.id)}
|
||||||
|
title="Delete"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end pt-2 border-t border-border/50">
|
||||||
|
<input
|
||||||
|
ref={scanInputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".glb,.gltf"
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleAddScan}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
ref={guideInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleAddGuide}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<button className="flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm bg-primary text-primary-foreground hover:bg-primary/90 cursor-pointer">
|
||||||
|
<Plus className="w-3.5 h-3.5" />
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent align="end" className="w-44 p-1">
|
||||||
|
<button
|
||||||
|
className="flex items-center gap-2 w-full px-3 py-2 rounded-md text-sm hover:bg-accent cursor-pointer"
|
||||||
|
onClick={() => scanInputRef.current?.click()}
|
||||||
|
>
|
||||||
|
<Box className="w-4 h-4" />
|
||||||
|
3D Scan
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="flex items-center gap-2 w-full px-3 py-2 rounded-md text-sm hover:bg-accent cursor-pointer"
|
||||||
|
onClick={() => guideInputRef.current?.click()}
|
||||||
|
>
|
||||||
|
<Image className="w-4 h-4" />
|
||||||
|
Guide Image
|
||||||
|
</button>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -58,6 +58,8 @@ type EditorState = {
|
|||||||
setSelectedItem: (item: Asset) => void
|
setSelectedItem: (item: Asset) => void
|
||||||
movingNode: ItemNode | null
|
movingNode: ItemNode | null
|
||||||
setMovingNode: (node: ItemNode | null) => void
|
setMovingNode: (node: ItemNode | null) => void
|
||||||
|
selectedReferenceId: string | null
|
||||||
|
setSelectedReferenceId: (id: string | null) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const useEditor = create<EditorState>()((set, get) => ({
|
const useEditor = create<EditorState>()((set, get) => ({
|
||||||
@@ -173,6 +175,8 @@ const useEditor = create<EditorState>()((set, get) => ({
|
|||||||
setSelectedItem: (item) => set({ selectedItem: item }),
|
setSelectedItem: (item) => set({ selectedItem: item }),
|
||||||
movingNode: null,
|
movingNode: null,
|
||||||
setMovingNode: (node) => set({ movingNode: node }),
|
setMovingNode: (node) => set({ movingNode: node }),
|
||||||
|
selectedReferenceId: null,
|
||||||
|
setSelectedReferenceId: (id) => set({ selectedReferenceId: id }),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
export default useEditor
|
export default useEditor
|
||||||
|
|||||||
@@ -58,6 +58,7 @@
|
|||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"dedent": "^1.7.1",
|
"dedent": "^1.7.1",
|
||||||
|
"idb-keyval": "^6.2.2",
|
||||||
"mitt": "^3.0.1",
|
"mitt": "^3.0.1",
|
||||||
"nanoid": "^5.1.6",
|
"nanoid": "^5.1.6",
|
||||||
"zod": "^4.3.5",
|
"zod": "^4.3.5",
|
||||||
@@ -695,6 +696,8 @@
|
|||||||
|
|
||||||
"hls.js": ["hls.js@1.6.15", "", {}, "sha512-E3a5VwgXimGHwpRGV+WxRTKeSp2DW5DI5MWv34ulL3t5UNmyJWCQ1KmLEHbYzcfThfXG8amBL+fCYPneGHC4VA=="],
|
"hls.js": ["hls.js@1.6.15", "", {}, "sha512-E3a5VwgXimGHwpRGV+WxRTKeSp2DW5DI5MWv34ulL3t5UNmyJWCQ1KmLEHbYzcfThfXG8amBL+fCYPneGHC4VA=="],
|
||||||
|
|
||||||
|
"idb-keyval": ["idb-keyval@6.2.2", "", {}, "sha512-yjD9nARJ/jb1g+CvD0tlhUHOrJ9Sy0P8T9MF3YaLlHnSRpwPfpTX0XIvpmw3gAJUmEu3FiICLBDPXVwyEvrleg=="],
|
||||||
|
|
||||||
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
|
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
|
||||||
|
|
||||||
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
|
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"dedent": "^1.7.1",
|
"dedent": "^1.7.1",
|
||||||
|
"idb-keyval": "^6.2.2",
|
||||||
"mitt": "^3.0.1",
|
"mitt": "^3.0.1",
|
||||||
"nanoid": "^5.1.6",
|
"nanoid": "^5.1.6",
|
||||||
"zod": "^4.3.5",
|
"zod": "^4.3.5",
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ export const sceneRegistry = {
|
|||||||
item: new Set<string>(),
|
item: new Set<string>(),
|
||||||
slab: new Set<string>(),
|
slab: new Set<string>(),
|
||||||
zone: new Set<string>(),
|
zone: new Set<string>(),
|
||||||
|
scan: new Set<string>(),
|
||||||
|
guide: new Set<string>(),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -34,3 +34,5 @@ export { SlabSystem } from './systems/slab/slab-system'
|
|||||||
export { WallSystem } from './systems/wall/wall-system'
|
export { WallSystem } from './systems/wall/wall-system'
|
||||||
|
|
||||||
export { isObject } from './utils/types'
|
export { isObject } from './utils/types'
|
||||||
|
// Asset storage
|
||||||
|
export { saveAsset, loadAssetUrl } from './lib/asset-storage'
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { get, set } from 'idb-keyval'
|
||||||
|
|
||||||
|
export const ASSET_PREFIX = 'asset_data:'
|
||||||
|
|
||||||
|
// Cache for active object URLs to prevent leaks and flickering
|
||||||
|
const urlCache = new Map<string, string>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save a file to IndexedDB and return a custom protocol URL
|
||||||
|
*/
|
||||||
|
export async function saveAsset(file: File): Promise<string> {
|
||||||
|
const id = crypto.randomUUID()
|
||||||
|
await set(`${ASSET_PREFIX}${id}`, file)
|
||||||
|
return `asset://${id}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load a file from IndexedDB and return an object URL
|
||||||
|
* If the URL is not a custom protocol URL, return it as is
|
||||||
|
*/
|
||||||
|
export async function loadAssetUrl(url: string): Promise<string | null> {
|
||||||
|
if (!url) return null
|
||||||
|
|
||||||
|
// If it's already a blob or http URL, return as is
|
||||||
|
if (url.startsWith('blob:') || url.startsWith('http')) {
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle our custom asset protocol
|
||||||
|
if (url.startsWith('asset://')) {
|
||||||
|
const id = url.replace('asset://', '')
|
||||||
|
|
||||||
|
// Check cache first
|
||||||
|
if (urlCache.has(id)) {
|
||||||
|
return urlCache.get(id)!
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const file = await get<File | Blob>(`${ASSET_PREFIX}${id}`)
|
||||||
|
if (!file) {
|
||||||
|
console.warn(`Asset not found: ${id}`)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const objectUrl = URL.createObjectURL(file)
|
||||||
|
urlCache.set(id, objectUrl)
|
||||||
|
return objectUrl
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load asset:', error)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy data URLs are returned as is
|
||||||
|
return url
|
||||||
|
}
|
||||||
@@ -13,6 +13,8 @@ export { BuildingNode } from './nodes/building'
|
|||||||
export { CeilingNode } from './nodes/ceiling'
|
export { CeilingNode } from './nodes/ceiling'
|
||||||
|
|
||||||
export { ZoneNode } from './nodes/zone'
|
export { ZoneNode } from './nodes/zone'
|
||||||
|
export { ScanNode } from './nodes/scan'
|
||||||
|
export { GuideNode } from './nodes/guide'
|
||||||
export type { AnyNodeId, AnyNodeType } from './types'
|
export type { AnyNodeId, AnyNodeType } from './types'
|
||||||
// Union types
|
// Union types
|
||||||
export { AnyNode } from './types'
|
export { AnyNode } from './types'
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { z } from 'zod'
|
||||||
|
import { BaseNode, nodeType, objectId } from '../base'
|
||||||
|
|
||||||
|
export const GuideNode = BaseNode.extend({
|
||||||
|
id: objectId('guide'),
|
||||||
|
type: nodeType('guide'),
|
||||||
|
url: z.string(),
|
||||||
|
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
|
||||||
|
rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
|
||||||
|
scale: z.number().default(1),
|
||||||
|
opacity: z.number().min(0).max(100).default(50),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type GuideNode = z.infer<typeof GuideNode>
|
||||||
@@ -2,6 +2,8 @@ import dedent from 'dedent'
|
|||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { BaseNode, nodeType, objectId } from '../base'
|
import { BaseNode, nodeType, objectId } from '../base'
|
||||||
import { CeilingNode } from './ceiling'
|
import { CeilingNode } from './ceiling'
|
||||||
|
import { GuideNode } from './guide'
|
||||||
|
import { ScanNode } from './scan'
|
||||||
import { SlabNode } from './slab'
|
import { SlabNode } from './slab'
|
||||||
import { WallNode } from './wall'
|
import { WallNode } from './wall'
|
||||||
import { ZoneNode } from './zone'
|
import { ZoneNode } from './zone'
|
||||||
@@ -9,7 +11,7 @@ import { ZoneNode } from './zone'
|
|||||||
export const LevelNode = BaseNode.extend({
|
export const LevelNode = BaseNode.extend({
|
||||||
id: objectId('level'),
|
id: objectId('level'),
|
||||||
type: nodeType('level'),
|
type: nodeType('level'),
|
||||||
children: z.array(z.union([WallNode.shape.id, ZoneNode.shape.id, SlabNode.shape.id, CeilingNode.shape.id])).default([]),
|
children: z.array(z.union([WallNode.shape.id, ZoneNode.shape.id, SlabNode.shape.id, CeilingNode.shape.id, ScanNode.shape.id, GuideNode.shape.id])).default([]),
|
||||||
// Specific props
|
// Specific props
|
||||||
level: z.number().default(0),
|
level: z.number().default(0),
|
||||||
}).describe(
|
}).describe(
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { z } from 'zod'
|
||||||
|
import { BaseNode, nodeType, objectId } from '../base'
|
||||||
|
|
||||||
|
export const ScanNode = BaseNode.extend({
|
||||||
|
id: objectId('scan'),
|
||||||
|
type: nodeType('scan'),
|
||||||
|
url: z.string(),
|
||||||
|
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
|
||||||
|
rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
|
||||||
|
scale: z.number().default(1),
|
||||||
|
opacity: z.number().min(0).max(100).default(100),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type ScanNode = z.infer<typeof ScanNode>
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
import z from 'zod'
|
import z from 'zod'
|
||||||
import { BuildingNode } from './nodes/building'
|
import { BuildingNode } from './nodes/building'
|
||||||
import { CeilingNode } from './nodes/ceiling'
|
import { CeilingNode } from './nodes/ceiling'
|
||||||
|
import { GuideNode } from './nodes/guide'
|
||||||
import { ItemNode } from './nodes/item'
|
import { ItemNode } from './nodes/item'
|
||||||
import { LevelNode } from './nodes/level'
|
import { LevelNode } from './nodes/level'
|
||||||
|
import { ScanNode } from './nodes/scan'
|
||||||
import { SiteNode } from './nodes/site'
|
import { SiteNode } from './nodes/site'
|
||||||
import { SlabNode } from './nodes/slab'
|
import { SlabNode } from './nodes/slab'
|
||||||
import { WallNode } from './nodes/wall'
|
import { WallNode } from './nodes/wall'
|
||||||
@@ -17,6 +19,8 @@ export const AnyNode = z.discriminatedUnion('type', [
|
|||||||
ZoneNode,
|
ZoneNode,
|
||||||
SlabNode,
|
SlabNode,
|
||||||
CeilingNode,
|
CeilingNode,
|
||||||
|
ScanNode,
|
||||||
|
GuideNode,
|
||||||
])
|
])
|
||||||
|
|
||||||
export type AnyNode = z.infer<typeof AnyNode>
|
export type AnyNode = z.infer<typeof AnyNode>
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { type GuideNode, useRegistry } from '@pascal-app/core'
|
||||||
|
import { Suspense, useMemo, useRef } from 'react'
|
||||||
|
import { DoubleSide, type Group, type Texture, TextureLoader } from 'three'
|
||||||
|
import { float, texture } from 'three/tsl'
|
||||||
|
import { MeshBasicNodeMaterial } from 'three/webgpu'
|
||||||
|
import { useLoader } from '@react-three/fiber'
|
||||||
|
import { useAssetUrl } from '../../../hooks/use-asset-url'
|
||||||
|
|
||||||
|
export const GuideRenderer = ({ node }: { node: GuideNode }) => {
|
||||||
|
const ref = useRef<Group>(null!)
|
||||||
|
useRegistry(node.id, 'guide', ref)
|
||||||
|
|
||||||
|
const resolvedUrl = useAssetUrl(node.url)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group
|
||||||
|
ref={ref}
|
||||||
|
position={node.position}
|
||||||
|
rotation={[0, node.rotation[1], 0]}
|
||||||
|
>
|
||||||
|
{resolvedUrl && (
|
||||||
|
<Suspense>
|
||||||
|
<GuidePlane url={resolvedUrl} scale={node.scale} opacity={node.opacity} />
|
||||||
|
</Suspense>
|
||||||
|
)}
|
||||||
|
</group>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const GuidePlane = ({ url, scale, opacity }: { url: string; scale: number; opacity: number }) => {
|
||||||
|
const tex = useLoader(TextureLoader, url) as Texture
|
||||||
|
|
||||||
|
const { width, height, material } = useMemo(() => {
|
||||||
|
const img = tex.image as HTMLImageElement | ImageBitmap
|
||||||
|
const w = img.width || 1
|
||||||
|
const h = img.height || 1
|
||||||
|
const aspect = w / h
|
||||||
|
|
||||||
|
// Default: 10 meters wide, height from aspect ratio
|
||||||
|
const planeWidth = 10 * scale
|
||||||
|
const planeHeight = (10 / aspect) * scale
|
||||||
|
|
||||||
|
const normalizedOpacity = opacity / 100
|
||||||
|
|
||||||
|
const mat = new MeshBasicNodeMaterial({
|
||||||
|
transparent: true,
|
||||||
|
colorNode: texture(tex),
|
||||||
|
opacityNode: float(normalizedOpacity),
|
||||||
|
side: DoubleSide,
|
||||||
|
depthWrite: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
return { width: planeWidth, height: planeHeight, material: mat }
|
||||||
|
}, [tex, scale, opacity])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<mesh rotation={[-Math.PI / 2, 0, 0]} material={material}>
|
||||||
|
<planeGeometry args={[width, height]} />
|
||||||
|
</mesh>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -3,8 +3,10 @@
|
|||||||
import { type AnyNode, useScene } from '@pascal-app/core'
|
import { type AnyNode, useScene } from '@pascal-app/core'
|
||||||
import { BuildingRenderer } from './building/building-renderer'
|
import { BuildingRenderer } from './building/building-renderer'
|
||||||
import { CeilingRenderer } from './ceiling/ceiling-renderer'
|
import { CeilingRenderer } from './ceiling/ceiling-renderer'
|
||||||
|
import { GuideRenderer } from './guide/guide-renderer'
|
||||||
import { ItemRenderer } from './item/item-renderer'
|
import { ItemRenderer } from './item/item-renderer'
|
||||||
import { LevelRenderer } from './level/level-renderer'
|
import { LevelRenderer } from './level/level-renderer'
|
||||||
|
import { ScanRenderer } from './scan/scan-renderer'
|
||||||
import { SlabRenderer } from './slab/slab-renderer'
|
import { SlabRenderer } from './slab/slab-renderer'
|
||||||
import { WallRenderer } from './wall/wall-renderer'
|
import { WallRenderer } from './wall/wall-renderer'
|
||||||
import { ZoneRenderer } from './zone/zone-renderer'
|
import { ZoneRenderer } from './zone/zone-renderer'
|
||||||
@@ -23,6 +25,8 @@ export const NodeRenderer = ({ nodeId }: { nodeId: AnyNode['id'] }) => {
|
|||||||
{node.type === 'slab' && <SlabRenderer node={node} />}
|
{node.type === 'slab' && <SlabRenderer node={node} />}
|
||||||
{node.type === 'wall' && <WallRenderer node={node} />}
|
{node.type === 'wall' && <WallRenderer node={node} />}
|
||||||
{node.type === 'zone' && <ZoneRenderer node={node} />}
|
{node.type === 'zone' && <ZoneRenderer node={node} />}
|
||||||
|
{node.type === 'scan' && <ScanRenderer node={node} />}
|
||||||
|
{node.type === 'guide' && <GuideRenderer node={node} />}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { type ScanNode, useRegistry } from '@pascal-app/core'
|
||||||
|
import { Clone } from '@react-three/drei/core/Clone'
|
||||||
|
import { Suspense, useMemo, useRef } from 'react'
|
||||||
|
import type { Group, Material, Mesh } from 'three'
|
||||||
|
import { useAssetUrl } from '../../../hooks/use-asset-url'
|
||||||
|
import { useGLTFKTX2 } from '../../../hooks/use-gltf-ktx2'
|
||||||
|
|
||||||
|
export const ScanRenderer = ({ node }: { node: ScanNode }) => {
|
||||||
|
const ref = useRef<Group>(null!)
|
||||||
|
useRegistry(node.id, 'scan', ref)
|
||||||
|
|
||||||
|
const resolvedUrl = useAssetUrl(node.url)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group
|
||||||
|
ref={ref}
|
||||||
|
position={node.position}
|
||||||
|
rotation={node.rotation}
|
||||||
|
scale={[node.scale, node.scale, node.scale]}
|
||||||
|
>
|
||||||
|
{resolvedUrl && (
|
||||||
|
<Suspense>
|
||||||
|
<ScanModel url={resolvedUrl} opacity={node.opacity} />
|
||||||
|
</Suspense>
|
||||||
|
)}
|
||||||
|
</group>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ScanModel = ({ url, opacity }: { url: string; opacity: number }) => {
|
||||||
|
const { scene } = useGLTFKTX2(url)
|
||||||
|
|
||||||
|
useMemo(() => {
|
||||||
|
const normalizedOpacity = opacity / 100
|
||||||
|
const isTransparent = normalizedOpacity < 1
|
||||||
|
|
||||||
|
const updateMaterial = (material: Material) => {
|
||||||
|
if (isTransparent) {
|
||||||
|
material.transparent = true
|
||||||
|
material.opacity = normalizedOpacity
|
||||||
|
} else {
|
||||||
|
material.transparent = false
|
||||||
|
material.opacity = 1
|
||||||
|
}
|
||||||
|
material.needsUpdate = true
|
||||||
|
}
|
||||||
|
|
||||||
|
scene.traverse((child) => {
|
||||||
|
if ((child as Mesh).isMesh) {
|
||||||
|
const mesh = child as Mesh
|
||||||
|
|
||||||
|
if (Array.isArray(mesh.material)) {
|
||||||
|
mesh.material.forEach((material) => {
|
||||||
|
updateMaterial(material)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
updateMaterial(mesh.material)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, [scene, opacity])
|
||||||
|
|
||||||
|
return <Clone object={scene} />
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { loadAssetUrl } from '@pascal-app/core'
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves an asset:// URL to a blob URL for use with Three.js loaders.
|
||||||
|
* Returns null while loading or if resolution fails.
|
||||||
|
*/
|
||||||
|
export function useAssetUrl(url: string): string | null {
|
||||||
|
const [resolved, setResolved] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
setResolved(null)
|
||||||
|
loadAssetUrl(url).then((result) => {
|
||||||
|
if (!cancelled) setResolved(result)
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [url])
|
||||||
|
|
||||||
|
return resolved
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { useGLTF } from "@react-three/drei"
|
||||||
|
import { useThree } from "@react-three/fiber"
|
||||||
|
import { KTX2Loader } from "three/examples/jsm/Addons.js"
|
||||||
|
import { MeshoptDecoder } from "three/examples/jsm/libs/meshopt_decoder.module.js"
|
||||||
|
|
||||||
|
const ktx2LoaderInstance = new KTX2Loader()
|
||||||
|
ktx2LoaderInstance.setTranscoderPath('https://cdn.jsdelivr.net/gh/pmndrs/drei-assets@master/basis/')
|
||||||
|
|
||||||
|
const useGLTFKTX2 = (path: string) => {
|
||||||
|
const gl = useThree((state) => state.gl)
|
||||||
|
|
||||||
|
return useGLTF(path, true, true, (loader) => {
|
||||||
|
ktx2LoaderInstance.detectSupport(gl)
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
loader.setKTX2Loader(ktx2LoaderInstance as any)
|
||||||
|
loader.setMeshoptDecoder(MeshoptDecoder)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
export { useGLTFKTX2 }
|
||||||
Reference in New Issue
Block a user