Merge pull request #100 from pascalorg/feat/window-editor
Feat/window editor
This commit is contained in:
@@ -5,6 +5,7 @@ import { useThree } from '@react-three/fiber'
|
|||||||
import { useEffect, useRef } from 'react'
|
import { useEffect, useRef } from 'react'
|
||||||
import * as THREE from 'three'
|
import * as THREE from 'three'
|
||||||
import { uploadProjectThumbnail } from '@/features/community/lib/projects/actions'
|
import { uploadProjectThumbnail } from '@/features/community/lib/projects/actions'
|
||||||
|
import { useProjectStore } from '@/features/community/lib/projects/store'
|
||||||
|
|
||||||
const THUMBNAIL_WIDTH = 1920
|
const THUMBNAIL_WIDTH = 1920
|
||||||
const THUMBNAIL_HEIGHT = 1080
|
const THUMBNAIL_HEIGHT = 1080
|
||||||
@@ -70,8 +71,7 @@ export const ThumbnailGenerator = ({ projectId: propProjectId }: ThumbnailGenera
|
|||||||
const result = await uploadProjectThumbnail(projectId, blob)
|
const result = await uploadProjectThumbnail(projectId, blob)
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
console.log('✅ Thumbnail uploaded successfully!')
|
useProjectStore.getState().updateActiveThumbnail(result.data.thumbnail_url)
|
||||||
console.log('🔗 URL:', result.data.thumbnail_url)
|
|
||||||
} else {
|
} else {
|
||||||
console.error('❌ Failed to upload thumbnail:', result.error)
|
console.error('❌ Failed to upload thumbnail:', result.error)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -237,6 +237,7 @@ export const CustomCameraControls = () => {
|
|||||||
mouseButtons={mouseButtons}
|
mouseButtons={mouseButtons}
|
||||||
onTransitionStart={onTransitionStart}
|
onTransitionStart={onTransitionStart}
|
||||||
onRest={onRest}
|
onRest={onRest}
|
||||||
|
onSleep={onRest}
|
||||||
restThreshold={0.01}
|
restThreshold={0.01}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { useAuth } from '@/features/community/lib/auth/hooks'
|
|||||||
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 { FeedbackDialog } from '../feedback-dialog'
|
||||||
import { PascalRadio } from '../pascal-radio'
|
import { PascalRadio } from '../pascal-radio'
|
||||||
import { PanelManager } from '../ui/panels/panel-manager'
|
import { PanelManager } from '../ui/panels/panel-manager'
|
||||||
import { HelperManager } from '../ui/helpers/helper-manager'
|
import { HelperManager } from '../ui/helpers/helper-manager'
|
||||||
@@ -56,10 +57,13 @@ export default function Editor({ projectId }: EditorProps) {
|
|||||||
<HelperManager />
|
<HelperManager />
|
||||||
|
|
||||||
{/* Top-right controls */}
|
{/* Top-right controls */}
|
||||||
<div className="pointer-events-none fixed top-4 right-4 z-50 flex items-start gap-2">
|
<div className="pointer-events-none fixed top-4 right-4 z-50 flex items-center gap-2">
|
||||||
<div className="pointer-events-auto">
|
<div className="pointer-events-auto">
|
||||||
<PascalRadio />
|
<PascalRadio />
|
||||||
</div>
|
</div>
|
||||||
|
<div className="pointer-events-auto">
|
||||||
|
<FeedbackDialog />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SidebarProvider className="fixed z-20">
|
<SidebarProvider className="fixed z-20">
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { MessageSquare } from 'lucide-react'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { submitFeedback } from '@/features/community/lib/feedback/actions'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/primitives/dialog'
|
||||||
|
import { Button } from '@/components/ui/primitives/button'
|
||||||
|
|
||||||
|
export function FeedbackDialog() {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [message, setMessage] = useState('')
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [sent, setSent] = useState(false)
|
||||||
|
|
||||||
|
const handleOpen = () => {
|
||||||
|
setOpen(true)
|
||||||
|
setSent(false)
|
||||||
|
setError(null)
|
||||||
|
setMessage('')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
if (isSubmitting) return
|
||||||
|
setOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setError(null)
|
||||||
|
setIsSubmitting(true)
|
||||||
|
const result = await submitFeedback(message)
|
||||||
|
setIsSubmitting(false)
|
||||||
|
if (result.success) {
|
||||||
|
setSent(true)
|
||||||
|
setTimeout(() => setOpen(false), 1500)
|
||||||
|
} else {
|
||||||
|
setError(result.error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={handleOpen}
|
||||||
|
className="flex items-center gap-2 rounded-lg border border-border bg-background/95 px-3 py-2 text-sm font-medium shadow-lg backdrop-blur-md hover:bg-accent/50 transition-colors"
|
||||||
|
>
|
||||||
|
<MessageSquare className="h-4 w-4" />
|
||||||
|
Feedback
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<Dialog open={open} onOpenChange={handleClose}>
|
||||||
|
<DialogContent className="sm:max-w-[460px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Send Feedback</DialogTitle>
|
||||||
|
<DialogDescription>We'd love to hear your thoughts</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{sent ? (
|
||||||
|
<p className="py-4 text-center text-sm text-muted-foreground">
|
||||||
|
Thanks for your feedback!
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<form className="space-y-4" onSubmit={handleSubmit}>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="feedback-message" className="text-sm font-medium">
|
||||||
|
Your feedback
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="feedback-message"
|
||||||
|
value={message}
|
||||||
|
onChange={(e) => setMessage(e.target.value)}
|
||||||
|
placeholder="Share your thoughts, suggestions, feature requests, or report issues..."
|
||||||
|
rows={5}
|
||||||
|
className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm resize-none focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="text-sm text-destructive">{error}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button type="button" variant="outline" onClick={handleClose} disabled={isSubmitting}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={isSubmitting || !message.trim()}>
|
||||||
|
{isSubmitting ? 'Sending...' : 'Send Feedback'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -73,8 +73,10 @@ export const CeilingTool: React.FC = () => {
|
|||||||
|
|
||||||
const [points, setPoints] = useState<Array<[number, number]>>([])
|
const [points, setPoints] = useState<Array<[number, number]>>([])
|
||||||
const [cursorPosition, setCursorPosition] = useState<[number, number]>([0, 0])
|
const [cursorPosition, setCursorPosition] = useState<[number, number]>([0, 0])
|
||||||
|
const [snappedCursorPosition, setSnappedCursorPosition] = useState<[number, number]>([0, 0])
|
||||||
const [levelY, setLevelY] = useState(0)
|
const [levelY, setLevelY] = useState(0)
|
||||||
const previousSnappedPointRef = useRef<[number, number] | null>(null)
|
const previousSnappedPointRef = useRef<[number, number] | null>(null)
|
||||||
|
const shiftPressed = useRef(false)
|
||||||
|
|
||||||
// Update cursor position and lines on grid move
|
// Update cursor position and lines on grid move
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -93,9 +95,10 @@ export const CeilingTool: React.FC = () => {
|
|||||||
const ceilingY = event.position[1] + CEILING_HEIGHT
|
const ceilingY = event.position[1] + CEILING_HEIGHT
|
||||||
const gridY = event.position[1] + GRID_OFFSET
|
const gridY = event.position[1] + GRID_OFFSET
|
||||||
|
|
||||||
// Calculate snapped display position
|
// Calculate snapped display position (bypass snap when Shift is held)
|
||||||
const lastPoint = points[points.length - 1]
|
const lastPoint = points[points.length - 1]
|
||||||
const displayPoint = lastPoint ? calculateSnapPoint(lastPoint, gridPosition) : gridPosition
|
const displayPoint = (shiftPressed.current || !lastPoint) ? gridPosition : calculateSnapPoint(lastPoint, gridPosition)
|
||||||
|
setSnappedCursorPosition(displayPoint)
|
||||||
|
|
||||||
// Play snap sound when the snapped position actually changes (only when drawing)
|
// Play snap sound when the snapped position actually changes (only when drawing)
|
||||||
if (points.length > 0 && previousSnappedPointRef.current &&
|
if (points.length > 0 && previousSnappedPointRef.current &&
|
||||||
@@ -111,9 +114,8 @@ export const CeilingTool: React.FC = () => {
|
|||||||
const onGridClick = (_event: GridEvent) => {
|
const onGridClick = (_event: GridEvent) => {
|
||||||
if (!currentLevelId) return
|
if (!currentLevelId) return
|
||||||
|
|
||||||
// Calculate snapped click point
|
// Use the last displayed snapped position (respects Shift state from onGridMove)
|
||||||
const lastPoint = points[points.length - 1]
|
const clickPoint = previousSnappedPointRef.current ?? cursorPosition
|
||||||
const clickPoint = lastPoint ? calculateSnapPoint(lastPoint, cursorPosition) : cursorPosition
|
|
||||||
|
|
||||||
// Check if clicking on the first point to close the shape
|
// Check if clicking on the first point to close the shape
|
||||||
const firstPoint = points[0]
|
const firstPoint = points[0]
|
||||||
@@ -148,12 +150,19 @@ export const CeilingTool: React.FC = () => {
|
|||||||
setPoints([])
|
setPoints([])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const onKeyDown = (e: KeyboardEvent) => { if (e.key === 'Shift') shiftPressed.current = true }
|
||||||
|
const onKeyUp = (e: KeyboardEvent) => { if (e.key === 'Shift') shiftPressed.current = false }
|
||||||
|
document.addEventListener('keydown', onKeyDown)
|
||||||
|
document.addEventListener('keyup', onKeyUp)
|
||||||
|
|
||||||
emitter.on('grid:move', onGridMove)
|
emitter.on('grid:move', onGridMove)
|
||||||
emitter.on('grid:click', onGridClick)
|
emitter.on('grid:click', onGridClick)
|
||||||
emitter.on('grid:double-click', onGridDoubleClick)
|
emitter.on('grid:double-click', onGridDoubleClick)
|
||||||
emitter.on('tool:cancel', onCancel)
|
emitter.on('tool:cancel', onCancel)
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
document.removeEventListener('keydown', onKeyDown)
|
||||||
|
document.removeEventListener('keyup', onKeyUp)
|
||||||
emitter.off('grid:move', onGridMove)
|
emitter.off('grid:move', onGridMove)
|
||||||
emitter.off('grid:click', onGridClick)
|
emitter.off('grid:click', onGridClick)
|
||||||
emitter.off('grid:double-click', onGridDoubleClick)
|
emitter.off('grid:double-click', onGridDoubleClick)
|
||||||
@@ -172,8 +181,7 @@ export const CeilingTool: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ceilingY = levelY + CEILING_HEIGHT
|
const ceilingY = levelY + CEILING_HEIGHT
|
||||||
const lastPoint = points[points.length - 1]
|
const snappedCursor = snappedCursorPosition
|
||||||
const snappedCursor = lastPoint ? calculateSnapPoint(lastPoint, cursorPosition) : cursorPosition
|
|
||||||
|
|
||||||
// Build main line points
|
// Build main line points
|
||||||
const linePoints: Vector3[] = points.map(([x, z]) => new Vector3(x, ceilingY, z))
|
const linePoints: Vector3[] = points.map(([x, z]) => new Vector3(x, ceilingY, z))
|
||||||
@@ -201,14 +209,13 @@ export const CeilingTool: React.FC = () => {
|
|||||||
} else {
|
} else {
|
||||||
closingLineRef.current.visible = false
|
closingLineRef.current.visible = false
|
||||||
}
|
}
|
||||||
}, [points, cursorPosition, levelY])
|
}, [points, snappedCursorPosition, levelY])
|
||||||
|
|
||||||
// Create preview shape when we have 3+ points
|
// Create preview shape when we have 3+ points
|
||||||
const previewShape = useMemo(() => {
|
const previewShape = useMemo(() => {
|
||||||
if (points.length < 3) return null
|
if (points.length < 3) return null
|
||||||
|
|
||||||
const lastPoint = points[points.length - 1]
|
const snappedCursor = snappedCursorPosition
|
||||||
const snappedCursor = lastPoint ? calculateSnapPoint(lastPoint, cursorPosition) : cursorPosition
|
|
||||||
|
|
||||||
const allPoints = [...points, snappedCursor]
|
const allPoints = [...points, snappedCursor]
|
||||||
|
|
||||||
@@ -230,7 +237,7 @@ export const CeilingTool: React.FC = () => {
|
|||||||
shape.closePath()
|
shape.closePath()
|
||||||
|
|
||||||
return shape
|
return shape
|
||||||
}, [points, cursorPosition])
|
}, [points, snappedCursorPosition])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<group>
|
<group>
|
||||||
|
|||||||
@@ -11,12 +11,12 @@ function getInitialState(node: {
|
|||||||
}): PlacementState {
|
}): PlacementState {
|
||||||
const attachTo = node.asset.attachTo
|
const attachTo = node.asset.attachTo
|
||||||
if (attachTo === 'wall' || attachTo === 'wall-side') {
|
if (attachTo === 'wall' || attachTo === 'wall-side') {
|
||||||
return { surface: 'wall', wallId: node.parentId, ceilingId: null }
|
return { surface: 'wall', wallId: node.parentId, ceilingId: null, surfaceItemId: null }
|
||||||
}
|
}
|
||||||
if (attachTo === 'ceiling') {
|
if (attachTo === 'ceiling') {
|
||||||
return { surface: 'ceiling', wallId: null, ceilingId: node.parentId }
|
return { surface: 'ceiling', wallId: null, ceilingId: node.parentId, surfaceItemId: null }
|
||||||
}
|
}
|
||||||
return { surface: 'floor', wallId: null, ceilingId: null }
|
return { surface: 'floor', wallId: null, ceilingId: null, surfaceItemId: null }
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MoveTool: React.FC = () => {
|
export const MoveTool: React.FC = () => {
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
import type {
|
import type {
|
||||||
AnyNode,
|
AnyNode,
|
||||||
|
AnyNodeId,
|
||||||
CeilingEvent,
|
CeilingEvent,
|
||||||
CeilingNode,
|
CeilingNode,
|
||||||
GridEvent,
|
GridEvent,
|
||||||
|
ItemEvent,
|
||||||
|
ItemNode,
|
||||||
WallEvent,
|
WallEvent,
|
||||||
WallNode,
|
WallNode,
|
||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
|
import { sceneRegistry, useScene } from '@pascal-app/core'
|
||||||
|
import { Vector3 } from 'three'
|
||||||
import type {
|
import type {
|
||||||
CommitResult,
|
CommitResult,
|
||||||
LevelResolver,
|
LevelResolver,
|
||||||
@@ -373,6 +378,105 @@ export const ceilingStrategy = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// ITEM SURFACE STRATEGY
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const itemSurfaceStrategy = {
|
||||||
|
/**
|
||||||
|
* Handle item:enter — transition from floor to an item surface.
|
||||||
|
* Returns null if: item has no surface, our item doesn't fit, or it's the draft itself.
|
||||||
|
*/
|
||||||
|
enter(ctx: PlacementContext, event: ItemEvent): TransitionResult | null {
|
||||||
|
// Only floor items can be placed on surfaces
|
||||||
|
if (ctx.asset.attachTo) return null
|
||||||
|
|
||||||
|
const surfaceItem = event.node as ItemNode
|
||||||
|
// Don't surface-place on the draft itself
|
||||||
|
if (surfaceItem.id === ctx.draftItem?.id) return null
|
||||||
|
// Surface item must declare a surface
|
||||||
|
if (!surfaceItem.asset.surface) return null
|
||||||
|
|
||||||
|
// Size check: our footprint must fit on surface item's footprint
|
||||||
|
const ourDims = ctx.asset.dimensions ?? DEFAULT_DIMENSIONS
|
||||||
|
const surfDims = surfaceItem.asset.dimensions
|
||||||
|
if (ourDims[0] > surfDims[0] || ourDims[2] > surfDims[2]) return null
|
||||||
|
|
||||||
|
const surfaceMesh = sceneRegistry.nodes.get(surfaceItem.id)
|
||||||
|
if (!surfaceMesh) return null
|
||||||
|
|
||||||
|
const worldPos = new Vector3(event.position[0], event.position[1], event.position[2])
|
||||||
|
const localPos = surfaceMesh.worldToLocal(worldPos)
|
||||||
|
|
||||||
|
const x = snapToGrid(localPos.x, ourDims[0])
|
||||||
|
const z = snapToGrid(localPos.z, ourDims[2])
|
||||||
|
const y = surfaceItem.asset.surface.height
|
||||||
|
|
||||||
|
const worldSnapped = surfaceMesh.localToWorld(new Vector3(x, y, z))
|
||||||
|
|
||||||
|
return {
|
||||||
|
stateUpdate: { surface: 'item-surface', surfaceItemId: surfaceItem.id },
|
||||||
|
nodeUpdate: { position: [x, y, z], parentId: surfaceItem.id },
|
||||||
|
cursorRotationY: 0,
|
||||||
|
gridPosition: [x, y, z],
|
||||||
|
cursorPosition: [worldSnapped.x, worldSnapped.y, worldSnapped.z],
|
||||||
|
stopPropagation: true,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle item:move — update position while on an item surface.
|
||||||
|
*/
|
||||||
|
move(ctx: PlacementContext, event: ItemEvent): PlacementResult | null {
|
||||||
|
if (ctx.state.surface !== 'item-surface') return null
|
||||||
|
if (!ctx.state.surfaceItemId || !ctx.draftItem) return null
|
||||||
|
|
||||||
|
const nodes = useScene.getState().nodes
|
||||||
|
const surfaceItem = nodes[ctx.state.surfaceItemId as AnyNodeId] as ItemNode | undefined
|
||||||
|
if (!surfaceItem?.asset.surface) return null
|
||||||
|
|
||||||
|
const surfaceMesh = sceneRegistry.nodes.get(ctx.state.surfaceItemId)
|
||||||
|
if (!surfaceMesh) return null
|
||||||
|
|
||||||
|
const ourDims = ctx.asset.dimensions ?? DEFAULT_DIMENSIONS
|
||||||
|
const worldPos = new Vector3(event.position[0], event.position[1], event.position[2])
|
||||||
|
const localPos = surfaceMesh.worldToLocal(worldPos)
|
||||||
|
|
||||||
|
const x = snapToGrid(localPos.x, ourDims[0])
|
||||||
|
const z = snapToGrid(localPos.z, ourDims[2])
|
||||||
|
const y = surfaceItem.asset.surface.height
|
||||||
|
|
||||||
|
const worldSnapped = surfaceMesh.localToWorld(new Vector3(x, y, z))
|
||||||
|
|
||||||
|
return {
|
||||||
|
gridPosition: [x, y, z],
|
||||||
|
cursorPosition: [worldSnapped.x, worldSnapped.y, worldSnapped.z],
|
||||||
|
cursorRotationY: 0,
|
||||||
|
nodeUpdate: { position: [x, y, z] },
|
||||||
|
stopPropagation: true,
|
||||||
|
dirtyNodeId: null,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle item:click — commit placement on item surface.
|
||||||
|
*/
|
||||||
|
click(ctx: PlacementContext, _event: ItemEvent): CommitResult | null {
|
||||||
|
if (ctx.state.surface !== 'item-surface') return null
|
||||||
|
if (!ctx.draftItem || !ctx.state.surfaceItemId) return null
|
||||||
|
|
||||||
|
return {
|
||||||
|
nodeUpdate: {
|
||||||
|
position: [ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z],
|
||||||
|
parentId: ctx.state.surfaceItemId,
|
||||||
|
metadata: stripTransient(ctx.draftItem.metadata),
|
||||||
|
},
|
||||||
|
stopPropagation: true,
|
||||||
|
dirtyNodeId: null,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// VALIDATION
|
// VALIDATION
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -384,6 +488,11 @@ export const ceilingStrategy = {
|
|||||||
export function checkCanPlace(ctx: PlacementContext, validators: SpatialValidators): boolean {
|
export function checkCanPlace(ctx: PlacementContext, validators: SpatialValidators): boolean {
|
||||||
if (!ctx.levelId || !ctx.draftItem) return false
|
if (!ctx.levelId || !ctx.draftItem) return false
|
||||||
|
|
||||||
|
// Item surface: valid if we entered (size check was in enter)
|
||||||
|
if (ctx.state.surface === 'item-surface') {
|
||||||
|
return ctx.state.surfaceItemId !== null
|
||||||
|
}
|
||||||
|
|
||||||
const attachTo = ctx.draftItem.asset.attachTo
|
const attachTo = ctx.draftItem.asset.attachTo
|
||||||
|
|
||||||
if (attachTo === 'ceiling') {
|
if (attachTo === 'ceiling') {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import type { Vector3 } from 'three'
|
|||||||
// PLACEMENT STATE
|
// PLACEMENT STATE
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
export type SurfaceType = 'floor' | 'wall' | 'ceiling'
|
export type SurfaceType = 'floor' | 'wall' | 'ceiling' | 'item-surface'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tracks which surface the draft item is currently on.
|
* Tracks which surface the draft item is currently on.
|
||||||
@@ -15,6 +15,7 @@ export interface PlacementState {
|
|||||||
surface: SurfaceType
|
surface: SurfaceType
|
||||||
wallId: string | null
|
wallId: string | null
|
||||||
ceilingId: string | null
|
ceilingId: string | null
|
||||||
|
surfaceItemId: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
type CeilingEvent,
|
type CeilingEvent,
|
||||||
emitter,
|
emitter,
|
||||||
type GridEvent,
|
type GridEvent,
|
||||||
|
type ItemEvent,
|
||||||
resolveLevelId,
|
resolveLevelId,
|
||||||
sceneRegistry,
|
sceneRegistry,
|
||||||
spatialGridManager,
|
spatialGridManager,
|
||||||
@@ -29,7 +30,7 @@ import {
|
|||||||
import { distance, smoothstep, uv, vec2 } from 'three/tsl'
|
import { distance, smoothstep, uv, vec2 } from 'three/tsl'
|
||||||
import { LineBasicNodeMaterial, MeshBasicNodeMaterial } from 'three/webgpu'
|
import { LineBasicNodeMaterial, MeshBasicNodeMaterial } from 'three/webgpu'
|
||||||
import { sfxEmitter } from '@/lib/sfx-bus'
|
import { sfxEmitter } from '@/lib/sfx-bus'
|
||||||
import { ceilingStrategy, checkCanPlace, floorStrategy, wallStrategy } from './placement-strategies'
|
import { ceilingStrategy, checkCanPlace, floorStrategy, itemSurfaceStrategy, wallStrategy } from './placement-strategies'
|
||||||
import type { PlacementState, TransitionResult } from './placement-types'
|
import type { PlacementState, TransitionResult } from './placement-types'
|
||||||
import type { DraftNodeHandle } from './use-draft-node'
|
import type { DraftNodeHandle } from './use-draft-node'
|
||||||
|
|
||||||
@@ -72,7 +73,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
const basePlaneRef = useRef<Mesh>(null!)
|
const basePlaneRef = useRef<Mesh>(null!)
|
||||||
const gridPosition = useRef(new Vector3(0, 0, 0))
|
const gridPosition = useRef(new Vector3(0, 0, 0))
|
||||||
const placementState = useRef<PlacementState>(
|
const placementState = useRef<PlacementState>(
|
||||||
config.initialState ?? { surface: 'floor', wallId: null, ceilingId: null },
|
config.initialState ?? { surface: 'floor', wallId: null, ceilingId: null, surfaceItemId: null },
|
||||||
)
|
)
|
||||||
const shiftFreeRef = useRef(false)
|
const shiftFreeRef = useRef(false)
|
||||||
|
|
||||||
@@ -93,6 +94,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
surface: 'floor',
|
surface: 'floor',
|
||||||
wallId: null,
|
wallId: null,
|
||||||
ceilingId: null,
|
ceilingId: null,
|
||||||
|
surfaceItemId: null,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Helpers ----
|
// ---- Helpers ----
|
||||||
@@ -367,6 +369,114 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Item Surface Handlers ----
|
||||||
|
|
||||||
|
const onItemEnter = (event: ItemEvent) => {
|
||||||
|
if (event.node.id === draftNode.current?.id) return
|
||||||
|
const result = itemSurfaceStrategy.enter(getContext(), event)
|
||||||
|
if (!result) return
|
||||||
|
|
||||||
|
event.stopPropagation()
|
||||||
|
applyTransition(result)
|
||||||
|
|
||||||
|
if (!draftNode.current) {
|
||||||
|
ensureDraft(result)
|
||||||
|
} else if (result.nodeUpdate.parentId) {
|
||||||
|
// Existing draft (move mode): reparent to surface item
|
||||||
|
useScene.getState().updateNode(draftNode.current.id, result.nodeUpdate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onItemMove = (event: ItemEvent) => {
|
||||||
|
if (event.node.id === draftNode.current?.id) return
|
||||||
|
const ctx = getContext()
|
||||||
|
|
||||||
|
if (ctx.state.surface !== 'item-surface') {
|
||||||
|
// Try entering surface mode
|
||||||
|
const enterResult = itemSurfaceStrategy.enter(ctx, event)
|
||||||
|
if (!enterResult) return
|
||||||
|
|
||||||
|
event.stopPropagation()
|
||||||
|
applyTransition(enterResult)
|
||||||
|
if (draftNode.current && enterResult.nodeUpdate.parentId) {
|
||||||
|
useScene.getState().updateNode(draftNode.current.id, enterResult.nodeUpdate)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!draftNode.current) {
|
||||||
|
const enterResult = itemSurfaceStrategy.enter(getContext(), event)
|
||||||
|
if (!enterResult) return
|
||||||
|
event.stopPropagation()
|
||||||
|
ensureDraft(enterResult)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = itemSurfaceStrategy.move(ctx, event)
|
||||||
|
if (!result) return
|
||||||
|
|
||||||
|
event.stopPropagation()
|
||||||
|
|
||||||
|
gridPosition.current.set(...result.gridPosition)
|
||||||
|
cursorGroupRef.current.position.set(...result.cursorPosition)
|
||||||
|
cursorGroupRef.current.rotation.y = result.cursorRotationY
|
||||||
|
|
||||||
|
const draft = draftNode.current
|
||||||
|
if (draft) {
|
||||||
|
draft.position = result.gridPosition
|
||||||
|
const mesh = sceneRegistry.nodes.get(draft.id)
|
||||||
|
if (mesh) mesh.position.set(...result.gridPosition)
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidate()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onItemLeave = (event: ItemEvent) => {
|
||||||
|
if (event.node.id === draftNode.current?.id) return
|
||||||
|
if (placementState.current.surface !== 'item-surface') return
|
||||||
|
|
||||||
|
event.stopPropagation()
|
||||||
|
|
||||||
|
// Transition back to floor using event world position
|
||||||
|
const wx = Math.round(event.position[0] * 2) / 2
|
||||||
|
const wz = Math.round(event.position[2] * 2) / 2
|
||||||
|
const floorPos: [number, number, number] = [wx, 0, wz]
|
||||||
|
|
||||||
|
Object.assign(placementState.current, { surface: 'floor', surfaceItemId: null })
|
||||||
|
gridPosition.current.set(wx, 0, wz)
|
||||||
|
cursorGroupRef.current.position.set(wx, event.position[1], wz)
|
||||||
|
|
||||||
|
const draft = draftNode.current
|
||||||
|
if (draft) {
|
||||||
|
draft.position = floorPos
|
||||||
|
useScene.getState().updateNode(draft.id, {
|
||||||
|
parentId: useViewer.getState().selection.levelId as string,
|
||||||
|
position: floorPos,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidate()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onItemClick = (event: ItemEvent) => {
|
||||||
|
if (event.node.id === draftNode.current?.id) return
|
||||||
|
const result = itemSurfaceStrategy.click(getContext(), event)
|
||||||
|
if (!result) return
|
||||||
|
|
||||||
|
event.stopPropagation()
|
||||||
|
draftNode.commit(result.nodeUpdate)
|
||||||
|
|
||||||
|
if (configRef.current.onCommitted()) {
|
||||||
|
// Try to set up next draft on the same surface
|
||||||
|
const enterResult = itemSurfaceStrategy.enter(getContext(), event)
|
||||||
|
if (enterResult) {
|
||||||
|
applyTransition(enterResult)
|
||||||
|
} else {
|
||||||
|
revalidate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Ceiling Handlers ----
|
// ---- Ceiling Handlers ----
|
||||||
|
|
||||||
const onCeilingEnter = (event: CeilingEvent) => {
|
const onCeilingEnter = (event: CeilingEvent) => {
|
||||||
@@ -546,6 +656,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
|
|
||||||
emitter.on('grid:move', onGridMove)
|
emitter.on('grid:move', onGridMove)
|
||||||
emitter.on('grid:click', onGridClick)
|
emitter.on('grid:click', onGridClick)
|
||||||
|
emitter.on('item:enter', onItemEnter)
|
||||||
|
emitter.on('item:move', onItemMove)
|
||||||
|
emitter.on('item:leave', onItemLeave)
|
||||||
|
emitter.on('item:click', onItemClick)
|
||||||
emitter.on('wall:enter', onWallEnter)
|
emitter.on('wall:enter', onWallEnter)
|
||||||
emitter.on('wall:move', onWallMove)
|
emitter.on('wall:move', onWallMove)
|
||||||
emitter.on('wall:click', onWallClick)
|
emitter.on('wall:click', onWallClick)
|
||||||
@@ -560,6 +674,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
useScene.temporal.getState().resume()
|
useScene.temporal.getState().resume()
|
||||||
emitter.off('grid:move', onGridMove)
|
emitter.off('grid:move', onGridMove)
|
||||||
emitter.off('grid:click', onGridClick)
|
emitter.off('grid:click', onGridClick)
|
||||||
|
emitter.off('item:enter', onItemEnter)
|
||||||
|
emitter.off('item:move', onItemMove)
|
||||||
|
emitter.off('item:leave', onItemLeave)
|
||||||
|
emitter.off('item:click', onItemClick)
|
||||||
emitter.off('wall:enter', onWallEnter)
|
emitter.off('wall:enter', onWallEnter)
|
||||||
emitter.off('wall:move', onWallMove)
|
emitter.off('wall:move', onWallMove)
|
||||||
emitter.off('wall:click', onWallClick)
|
emitter.off('wall:click', onWallClick)
|
||||||
|
|||||||
@@ -71,8 +71,10 @@ export const SlabTool: React.FC = () => {
|
|||||||
|
|
||||||
const [points, setPoints] = useState<Array<[number, number]>>([])
|
const [points, setPoints] = useState<Array<[number, number]>>([])
|
||||||
const [cursorPosition, setCursorPosition] = useState<[number, number]>([0, 0])
|
const [cursorPosition, setCursorPosition] = useState<[number, number]>([0, 0])
|
||||||
|
const [snappedCursorPosition, setSnappedCursorPosition] = useState<[number, number]>([0, 0])
|
||||||
const [levelY, setLevelY] = useState(0)
|
const [levelY, setLevelY] = useState(0)
|
||||||
const previousSnappedPointRef = useRef<[number, number] | null>(null)
|
const previousSnappedPointRef = useRef<[number, number] | null>(null)
|
||||||
|
const shiftPressed = useRef(false)
|
||||||
|
|
||||||
// Update cursor position and lines on grid move
|
// Update cursor position and lines on grid move
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -88,9 +90,10 @@ export const SlabTool: React.FC = () => {
|
|||||||
setCursorPosition(gridPosition)
|
setCursorPosition(gridPosition)
|
||||||
setLevelY(event.position[1])
|
setLevelY(event.position[1])
|
||||||
|
|
||||||
// Calculate snapped display position
|
// Calculate snapped display position (bypass snap when Shift is held)
|
||||||
const lastPoint = points[points.length - 1]
|
const lastPoint = points[points.length - 1]
|
||||||
const displayPoint = lastPoint ? calculateSnapPoint(lastPoint, gridPosition) : gridPosition
|
const displayPoint = (shiftPressed.current || !lastPoint) ? gridPosition : calculateSnapPoint(lastPoint, gridPosition)
|
||||||
|
setSnappedCursorPosition(displayPoint)
|
||||||
|
|
||||||
// Play snap sound when the snapped position actually changes (only when drawing)
|
// Play snap sound when the snapped position actually changes (only when drawing)
|
||||||
if (points.length > 0 && previousSnappedPointRef.current &&
|
if (points.length > 0 && previousSnappedPointRef.current &&
|
||||||
@@ -105,9 +108,8 @@ export const SlabTool: React.FC = () => {
|
|||||||
const onGridClick = (_event: GridEvent) => {
|
const onGridClick = (_event: GridEvent) => {
|
||||||
if (!currentLevelId) return
|
if (!currentLevelId) return
|
||||||
|
|
||||||
// Calculate snapped click point
|
// Use the last displayed snapped position (respects Shift state from onGridMove)
|
||||||
const lastPoint = points[points.length - 1]
|
const clickPoint = previousSnappedPointRef.current ?? cursorPosition
|
||||||
const clickPoint = lastPoint ? calculateSnapPoint(lastPoint, cursorPosition) : cursorPosition
|
|
||||||
|
|
||||||
// Check if clicking on the first point to close the shape
|
// Check if clicking on the first point to close the shape
|
||||||
const firstPoint = points[0]
|
const firstPoint = points[0]
|
||||||
@@ -142,12 +144,19 @@ export const SlabTool: React.FC = () => {
|
|||||||
setPoints([])
|
setPoints([])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const onKeyDown = (e: KeyboardEvent) => { if (e.key === 'Shift') shiftPressed.current = true }
|
||||||
|
const onKeyUp = (e: KeyboardEvent) => { if (e.key === 'Shift') shiftPressed.current = false }
|
||||||
|
document.addEventListener('keydown', onKeyDown)
|
||||||
|
document.addEventListener('keyup', onKeyUp)
|
||||||
|
|
||||||
emitter.on('grid:move', onGridMove)
|
emitter.on('grid:move', onGridMove)
|
||||||
emitter.on('grid:click', onGridClick)
|
emitter.on('grid:click', onGridClick)
|
||||||
emitter.on('grid:double-click', onGridDoubleClick)
|
emitter.on('grid:double-click', onGridDoubleClick)
|
||||||
emitter.on('tool:cancel', onCancel)
|
emitter.on('tool:cancel', onCancel)
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
document.removeEventListener('keydown', onKeyDown)
|
||||||
|
document.removeEventListener('keyup', onKeyUp)
|
||||||
emitter.off('grid:move', onGridMove)
|
emitter.off('grid:move', onGridMove)
|
||||||
emitter.off('grid:click', onGridClick)
|
emitter.off('grid:click', onGridClick)
|
||||||
emitter.off('grid:double-click', onGridDoubleClick)
|
emitter.off('grid:double-click', onGridDoubleClick)
|
||||||
@@ -166,8 +175,7 @@ export const SlabTool: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const y = levelY + Y_OFFSET
|
const y = levelY + Y_OFFSET
|
||||||
const lastPoint = points[points.length - 1]
|
const snappedCursor = snappedCursorPosition
|
||||||
const snappedCursor = lastPoint ? calculateSnapPoint(lastPoint, cursorPosition) : cursorPosition
|
|
||||||
|
|
||||||
// Build main line points
|
// Build main line points
|
||||||
const linePoints: Vector3[] = points.map(([x, z]) => new Vector3(x, y, z))
|
const linePoints: Vector3[] = points.map(([x, z]) => new Vector3(x, y, z))
|
||||||
@@ -195,14 +203,13 @@ export const SlabTool: React.FC = () => {
|
|||||||
} else {
|
} else {
|
||||||
closingLineRef.current.visible = false
|
closingLineRef.current.visible = false
|
||||||
}
|
}
|
||||||
}, [points, cursorPosition, levelY])
|
}, [points, snappedCursorPosition, levelY])
|
||||||
|
|
||||||
// Create preview shape when we have 3+ points
|
// Create preview shape when we have 3+ points
|
||||||
const previewShape = useMemo(() => {
|
const previewShape = useMemo(() => {
|
||||||
if (points.length < 3) return null
|
if (points.length < 3) return null
|
||||||
|
|
||||||
const lastPoint = points[points.length - 1]
|
const snappedCursor = snappedCursorPosition
|
||||||
const snappedCursor = lastPoint ? calculateSnapPoint(lastPoint, cursorPosition) : cursorPosition
|
|
||||||
|
|
||||||
const allPoints = [...points, snappedCursor]
|
const allPoints = [...points, snappedCursor]
|
||||||
|
|
||||||
@@ -224,7 +231,7 @@ export const SlabTool: React.FC = () => {
|
|||||||
shape.closePath()
|
shape.closePath()
|
||||||
|
|
||||||
return shape
|
return shape
|
||||||
}, [points, cursorPosition])
|
}, [points, snappedCursorPosition])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<group>
|
<group>
|
||||||
|
|||||||
@@ -280,13 +280,15 @@ export const WindowTool: React.FC = () => {
|
|||||||
frameDepth: draft.frameDepth,
|
frameDepth: draft.frameDepth,
|
||||||
columnRatios: draft.columnRatios,
|
columnRatios: draft.columnRatios,
|
||||||
rowRatios: draft.rowRatios,
|
rowRatios: draft.rowRatios,
|
||||||
dividerThickness: draft.dividerThickness,
|
columnDividerThickness: draft.columnDividerThickness,
|
||||||
|
rowDividerThickness: draft.rowDividerThickness,
|
||||||
sill: draft.sill,
|
sill: draft.sill,
|
||||||
sillDepth: draft.sillDepth,
|
sillDepth: draft.sillDepth,
|
||||||
sillThickness: draft.sillThickness,
|
sillThickness: draft.sillThickness,
|
||||||
})
|
})
|
||||||
|
|
||||||
useScene.getState().createNode(node, event.node.id as AnyNodeId)
|
useScene.getState().createNode(node, event.node.id as AnyNodeId)
|
||||||
|
useViewer.getState().setSelection({ selectedIds: [node.id] })
|
||||||
useScene.temporal.getState().pause()
|
useScene.temporal.getState().pause()
|
||||||
|
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
export function CeilingHelper() {
|
export function CeilingHelper() {
|
||||||
return (
|
return (
|
||||||
<div className="pointer-events-none fixed right-4 top-1/2 -translate-y-1/2 z-40 flex flex-col gap-2 rounded-lg border border-border bg-background/95 px-4 py-3 shadow-lg backdrop-blur-md">
|
<div className="pointer-events-none fixed right-4 top-1/2 -translate-y-1/2 z-40 flex flex-col gap-2 rounded-lg border border-border bg-background/95 px-4 py-3 shadow-lg backdrop-blur-md">
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<kbd className="rounded bg-muted px-2 py-1 text-xs font-medium">Shift</kbd>
|
||||||
|
<span className="text-muted-foreground">Allow non-45° angles</span>
|
||||||
|
</div>
|
||||||
<div className="flex items-center gap-2 text-sm">
|
<div className="flex items-center gap-2 text-sm">
|
||||||
<kbd className="rounded bg-muted px-2 py-1 text-xs font-medium">Esc</kbd>
|
<kbd className="rounded bg-muted px-2 py-1 text-xs font-medium">Esc</kbd>
|
||||||
<span className="text-muted-foreground">Cancel</span>
|
<span className="text-muted-foreground">Cancel</span>
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
export function SlabHelper() {
|
export function SlabHelper() {
|
||||||
return (
|
return (
|
||||||
<div className="pointer-events-none fixed right-4 top-1/2 -translate-y-1/2 z-40 flex flex-col gap-2 rounded-lg border border-border bg-background/95 px-4 py-3 shadow-lg backdrop-blur-md">
|
<div className="pointer-events-none fixed right-4 top-1/2 -translate-y-1/2 z-40 flex flex-col gap-2 rounded-lg border border-border bg-background/95 px-4 py-3 shadow-lg backdrop-blur-md">
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<kbd className="rounded bg-muted px-2 py-1 text-xs font-medium">Shift</kbd>
|
||||||
|
<span className="text-muted-foreground">Allow non-45° angles</span>
|
||||||
|
</div>
|
||||||
<div className="flex items-center gap-2 text-sm">
|
<div className="flex items-center gap-2 text-sm">
|
||||||
<kbd className="rounded bg-muted px-2 py-1 text-xs font-medium">Esc</kbd>
|
<kbd className="rounded bg-muted px-2 py-1 text-xs font-medium">Esc</kbd>
|
||||||
<span className="text-muted-foreground">Cancel</span>
|
<span className="text-muted-foreground">Cancel</span>
|
||||||
|
|||||||
@@ -435,6 +435,9 @@ export const CATALOG_ITEMS: AssetInput[] = [
|
|||||||
offset: [0, 0, 0],
|
offset: [0, 0, 0],
|
||||||
rotation: [0, 0, 0],
|
rotation: [0, 0, 0],
|
||||||
dimensions: [2, 0.8, 1],
|
dimensions: [2, 0.8, 1],
|
||||||
|
surface: {
|
||||||
|
height: 0.75
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
@@ -448,6 +451,9 @@ export const CATALOG_ITEMS: AssetInput[] = [
|
|||||||
offset: [0, 0, 0],
|
offset: [0, 0, 0],
|
||||||
rotation: [0, 0, 0],
|
rotation: [0, 0, 0],
|
||||||
dimensions: [2, 1.1, 1],
|
dimensions: [2, 1.1, 1],
|
||||||
|
surface: {
|
||||||
|
height: 1.1
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
@@ -1161,7 +1167,9 @@ export const CATALOG_ITEMS: AssetInput[] = [
|
|||||||
scale: [1, 1, 1],
|
scale: [1, 1, 1],
|
||||||
offset: [0, 0.21, 0],
|
offset: [0, 0.21, 0],
|
||||||
rotation: [0, 0, 0],
|
rotation: [0, 0, 0],
|
||||||
dimensions: [2, 0.4, 0.5],
|
dimensions: [2, 0.4, 0.5],surface: {
|
||||||
|
height: 0.36
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
@@ -1176,6 +1184,9 @@ export const CATALOG_ITEMS: AssetInput[] = [
|
|||||||
rotation: [0, 0, 0],
|
rotation: [0, 0, 0],
|
||||||
dimensions: [1, 0.5, 0.7],
|
dimensions: [1, 0.5, 0.7],
|
||||||
attachTo: "wall-side",
|
attachTo: "wall-side",
|
||||||
|
surface: {
|
||||||
|
height: 0.12
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
@@ -1255,6 +1266,9 @@ export const CATALOG_ITEMS: AssetInput[] = [
|
|||||||
offset: [0, 0, 0],
|
offset: [0, 0, 0],
|
||||||
rotation: [0, 0, 0],
|
rotation: [0, 0, 0],
|
||||||
dimensions: [1.5, 0.8, 1],
|
dimensions: [1.5, 0.8, 1],
|
||||||
|
surface: {
|
||||||
|
height: 0.8
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
@@ -1385,6 +1399,9 @@ export const CATALOG_ITEMS: AssetInput[] = [
|
|||||||
offset: [0, 0, -0.01],
|
offset: [0, 0, -0.01],
|
||||||
rotation: [0, 0, 0],
|
rotation: [0, 0, 0],
|
||||||
dimensions: [0.5, 0.5, 0.5],
|
dimensions: [0.5, 0.5, 0.5],
|
||||||
|
surface: {
|
||||||
|
height: 0.5
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
@@ -1398,6 +1415,9 @@ export const CATALOG_ITEMS: AssetInput[] = [
|
|||||||
offset: [0, 0, 0],
|
offset: [0, 0, 0],
|
||||||
rotation: [0, 0, 0],
|
rotation: [0, 0, 0],
|
||||||
dimensions: [2, 0.4, 1.5],
|
dimensions: [2, 0.4, 1.5],
|
||||||
|
surface: {
|
||||||
|
height: 0.3
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
@@ -1411,6 +1431,9 @@ export const CATALOG_ITEMS: AssetInput[] = [
|
|||||||
offset: [0, 0, 0],
|
offset: [0, 0, 0],
|
||||||
rotation: [0, 0, 0],
|
rotation: [0, 0, 0],
|
||||||
dimensions: [2, 0.8, 1],
|
dimensions: [2, 0.8, 1],
|
||||||
|
surface: {
|
||||||
|
height: 0.75
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
@@ -1424,5 +1447,8 @@ export const CATALOG_ITEMS: AssetInput[] = [
|
|||||||
offset: [0, 0, -0.01],
|
offset: [0, 0, -0.01],
|
||||||
rotation: [0, 0, 0],
|
rotation: [0, 0, 0],
|
||||||
dimensions: [2.5, 0.8, 1],
|
dimensions: [2.5, 0.8, 1],
|
||||||
|
surface: {
|
||||||
|
height: 0.8
|
||||||
|
}
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import { ItemPanel } from './item-panel'
|
|||||||
import { ReferencePanel } from './reference-panel'
|
import { ReferencePanel } from './reference-panel'
|
||||||
import { RoofPanel } from './roof-panel'
|
import { RoofPanel } from './roof-panel'
|
||||||
import { SlabPanel } from './slab-panel'
|
import { SlabPanel } from './slab-panel'
|
||||||
|
import { WallPanel } from './wall-panel'
|
||||||
|
import { WindowPanel } from './window-panel'
|
||||||
|
|
||||||
export function PanelManager() {
|
export function PanelManager() {
|
||||||
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
||||||
@@ -33,6 +35,10 @@ export function PanelManager() {
|
|||||||
return <SlabPanel />
|
return <SlabPanel />
|
||||||
case 'ceiling':
|
case 'ceiling':
|
||||||
return <CeilingPanel />
|
return <CeilingPanel />
|
||||||
|
case 'wall':
|
||||||
|
return <WallPanel />
|
||||||
|
case 'window':
|
||||||
|
return <WindowPanel />
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { type AnyNode, type AnyNodeId, type WallNode, useScene } from '@pascal-app/core'
|
||||||
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
|
import { X } from 'lucide-react'
|
||||||
|
import Image from 'next/image'
|
||||||
|
import { useCallback } from 'react'
|
||||||
|
import { NumberInput } from '@/components/ui/primitives/number-input'
|
||||||
|
|
||||||
|
export function WallPanel() {
|
||||||
|
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
||||||
|
const setSelection = useViewer((s) => s.setSelection)
|
||||||
|
const nodes = useScene((s) => s.nodes)
|
||||||
|
const updateNode = useScene((s) => s.updateNode)
|
||||||
|
|
||||||
|
const selectedId = selectedIds[0]
|
||||||
|
const node = selectedId
|
||||||
|
? (nodes[selectedId as AnyNode['id']] as WallNode | undefined)
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
const handleUpdate = useCallback(
|
||||||
|
(updates: Partial<WallNode>) => {
|
||||||
|
if (!selectedId) return
|
||||||
|
updateNode(selectedId as AnyNode['id'], updates)
|
||||||
|
useScene.getState().dirtyNodes.add(selectedId as AnyNodeId)
|
||||||
|
},
|
||||||
|
[selectedId, updateNode],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleClose = useCallback(() => {
|
||||||
|
setSelection({ selectedIds: [] })
|
||||||
|
}, [setSelection])
|
||||||
|
|
||||||
|
if (!node || node.type !== 'wall' || selectedIds.length !== 1) return null
|
||||||
|
|
||||||
|
const dx = node.end[0] - node.start[0]
|
||||||
|
const dz = node.end[1] - node.start[1]
|
||||||
|
const length = Math.sqrt(dx * dx + dz * dz)
|
||||||
|
|
||||||
|
const height = node.height ?? 2.5
|
||||||
|
const thickness = node.thickness ?? 0.1
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pointer-events-auto fixed top-20 right-4 z-50 flex w-64 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 gap-2 border-b p-3">
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<Image src="/icons/wall.png" alt="" width={16} height={16} className="shrink-0 object-contain" />
|
||||||
|
<h2 className="font-semibold text-foreground text-sm truncate">
|
||||||
|
{node.name || `Wall (${length.toFixed(2)}m)`}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="shrink-0 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 space-y-4">
|
||||||
|
|
||||||
|
{/* Dimensions */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
|
||||||
|
Dimensions
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<NumberInput
|
||||||
|
label="Height"
|
||||||
|
value={Math.round(height * 100) / 100}
|
||||||
|
onChange={(v) => handleUpdate({ height: Math.max(0.1, v) })}
|
||||||
|
min={0.1}
|
||||||
|
precision={2}
|
||||||
|
step={0.1}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<span className="text-muted-foreground text-xs shrink-0">m</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<NumberInput
|
||||||
|
label="Thickness"
|
||||||
|
value={Math.round(thickness * 1000) / 1000}
|
||||||
|
onChange={(v) => handleUpdate({ thickness: Math.max(0.05, v) })}
|
||||||
|
min={0.05}
|
||||||
|
precision={3}
|
||||||
|
step={0.01}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<span className="text-muted-foreground text-xs shrink-0">m</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
|
||||||
|
Info
|
||||||
|
</label>
|
||||||
|
<div className="rounded border border-border bg-muted/50 px-3 py-2 text-sm">
|
||||||
|
Length: {length.toFixed(2)} m
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,339 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { type AnyNode, type AnyNodeId, type WindowNode, useScene } from '@pascal-app/core'
|
||||||
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
|
import { FlipHorizontal2, X } from 'lucide-react'
|
||||||
|
import Image from 'next/image'
|
||||||
|
import { useCallback } from 'react'
|
||||||
|
import { NumberInput } from '@/components/ui/primitives/number-input'
|
||||||
|
import { Switch } from '@/components/ui/primitives/switch'
|
||||||
|
|
||||||
|
export function WindowPanel() {
|
||||||
|
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
||||||
|
const setSelection = useViewer((s) => s.setSelection)
|
||||||
|
const nodes = useScene((s) => s.nodes)
|
||||||
|
const updateNode = useScene((s) => s.updateNode)
|
||||||
|
|
||||||
|
const selectedId = selectedIds[0]
|
||||||
|
const node = selectedId
|
||||||
|
? (nodes[selectedId as AnyNode['id']] as WindowNode | undefined)
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
const handleUpdate = useCallback(
|
||||||
|
(updates: Partial<WindowNode>) => {
|
||||||
|
if (!selectedId) return
|
||||||
|
updateNode(selectedId as AnyNode['id'], updates)
|
||||||
|
useScene.getState().dirtyNodes.add(selectedId as AnyNodeId)
|
||||||
|
},
|
||||||
|
[selectedId, updateNode],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleClose = useCallback(() => {
|
||||||
|
setSelection({ selectedIds: [] })
|
||||||
|
}, [setSelection])
|
||||||
|
|
||||||
|
const handleFlip = useCallback(() => {
|
||||||
|
if (!node) return
|
||||||
|
handleUpdate({
|
||||||
|
side: node.side === 'front' ? 'back' : 'front',
|
||||||
|
rotation: [node.rotation[0], node.rotation[1] + Math.PI, node.rotation[2]],
|
||||||
|
})
|
||||||
|
}, [node, handleUpdate])
|
||||||
|
|
||||||
|
if (!node || node.type !== 'window' || selectedIds.length !== 1) return null
|
||||||
|
|
||||||
|
const numCols = node.columnRatios.length
|
||||||
|
const numRows = node.rowRatios.length
|
||||||
|
|
||||||
|
// Normalized ratios (always sum to 1 for display)
|
||||||
|
const colSum = node.columnRatios.reduce((a, b) => a + b, 0)
|
||||||
|
const rowSum = node.rowRatios.reduce((a, b) => a + b, 0)
|
||||||
|
const normCols = node.columnRatios.map(r => r / colSum)
|
||||||
|
const normRows = node.rowRatios.map(r => r / rowSum)
|
||||||
|
|
||||||
|
const setColumnRatio = (index: number, newVal: number) => {
|
||||||
|
const clamped = Math.max(0.05, Math.min(0.95, newVal))
|
||||||
|
const neighborIdx = index < numCols - 1 ? index + 1 : index - 1
|
||||||
|
const delta = clamped - normCols[index]!
|
||||||
|
const neighborVal = Math.max(0.05, normCols[neighborIdx]! - delta)
|
||||||
|
const newRatios = normCols.map((v, i) => {
|
||||||
|
if (i === index) return clamped
|
||||||
|
if (i === neighborIdx) return neighborVal
|
||||||
|
return v
|
||||||
|
})
|
||||||
|
handleUpdate({ columnRatios: newRatios })
|
||||||
|
}
|
||||||
|
|
||||||
|
const setRowRatio = (index: number, newVal: number) => {
|
||||||
|
const clamped = Math.max(0.05, Math.min(0.95, newVal))
|
||||||
|
const neighborIdx = index < numRows - 1 ? index + 1 : index - 1
|
||||||
|
const delta = clamped - normRows[index]!
|
||||||
|
const neighborVal = Math.max(0.05, normRows[neighborIdx]! - delta)
|
||||||
|
const newRatios = normRows.map((v, i) => {
|
||||||
|
if (i === index) return clamped
|
||||||
|
if (i === neighborIdx) return neighborVal
|
||||||
|
return v
|
||||||
|
})
|
||||||
|
handleUpdate({ rowRatios: newRatios })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pointer-events-auto fixed top-20 right-4 z-50 flex w-82 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 gap-2 border-b p-3">
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<Image src="/icons/window.png" alt="" width={16} height={16} className="shrink-0 object-contain" />
|
||||||
|
<h2 className="font-semibold text-foreground text-sm truncate">
|
||||||
|
{node.name || `Window (${node.width}×${node.height}m)`}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="shrink-0 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 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-2 gap-2">
|
||||||
|
<NumberInput
|
||||||
|
label="X"
|
||||||
|
value={Math.round(node.position[0] * 100) / 100}
|
||||||
|
onChange={(v) => handleUpdate({ position: [v, node.position[1], node.position[2]] })}
|
||||||
|
precision={2}
|
||||||
|
/>
|
||||||
|
<NumberInput
|
||||||
|
label="Y"
|
||||||
|
value={Math.round(node.position[1] * 100) / 100}
|
||||||
|
onChange={(v) => handleUpdate({ position: [node.position[0], v, node.position[2]] })}
|
||||||
|
precision={2}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="w-full flex items-center justify-center gap-1.5 rounded border border-border px-2 py-1.5 text-xs hover:bg-accent cursor-pointer"
|
||||||
|
onClick={handleFlip}
|
||||||
|
>
|
||||||
|
<FlipHorizontal2 className="h-3.5 w-3.5" />
|
||||||
|
Flip Side
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Dimensions */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
|
||||||
|
Dimensions
|
||||||
|
</label>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<NumberInput
|
||||||
|
label="Width"
|
||||||
|
value={Math.round(node.width * 100) / 100}
|
||||||
|
onChange={(v) => handleUpdate({ width: v })}
|
||||||
|
min={0.2}
|
||||||
|
precision={2}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<span className="text-muted-foreground text-xs shrink-0">m</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<NumberInput
|
||||||
|
label="Height"
|
||||||
|
value={Math.round(node.height * 100) / 100}
|
||||||
|
onChange={(v) => handleUpdate({ height: v })}
|
||||||
|
min={0.2}
|
||||||
|
precision={2}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<span className="text-muted-foreground text-xs shrink-0">m</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Frame */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
|
||||||
|
Frame
|
||||||
|
</label>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<NumberInput
|
||||||
|
label="Thickness"
|
||||||
|
value={Math.round(node.frameThickness * 1000) / 1000}
|
||||||
|
onChange={(v) => handleUpdate({ frameThickness: v })}
|
||||||
|
min={0.01}
|
||||||
|
precision={3}
|
||||||
|
step={0.01}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<span className="text-muted-foreground text-xs shrink-0">m</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<NumberInput
|
||||||
|
label="Depth"
|
||||||
|
value={Math.round(node.frameDepth * 1000) / 1000}
|
||||||
|
onChange={(v) => handleUpdate({ frameDepth: v })}
|
||||||
|
min={0.01}
|
||||||
|
precision={3}
|
||||||
|
step={0.01}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<span className="text-muted-foreground text-xs shrink-0">m</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Grid */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
|
||||||
|
Grid
|
||||||
|
</label>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<NumberInput
|
||||||
|
label="Columns"
|
||||||
|
value={numCols}
|
||||||
|
onChange={(v) => {
|
||||||
|
const n = Math.max(1, Math.min(8, Math.round(v)))
|
||||||
|
handleUpdate({ columnRatios: Array(n).fill(1 / n) })
|
||||||
|
}}
|
||||||
|
min={1}
|
||||||
|
max={8}
|
||||||
|
precision={0}
|
||||||
|
step={1}
|
||||||
|
/>
|
||||||
|
<NumberInput
|
||||||
|
label="Rows"
|
||||||
|
value={numRows}
|
||||||
|
onChange={(v) => {
|
||||||
|
const n = Math.max(1, Math.min(8, Math.round(v)))
|
||||||
|
handleUpdate({ rowRatios: Array(n).fill(1 / n) })
|
||||||
|
}}
|
||||||
|
min={1}
|
||||||
|
max={8}
|
||||||
|
precision={0}
|
||||||
|
step={1}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Column ratios */}
|
||||||
|
{numCols > 1 && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<span className="text-muted-foreground text-xs">Column widths</span>
|
||||||
|
{normCols.map((ratio, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-1.5">
|
||||||
|
<NumberInput
|
||||||
|
label={`C${i + 1}`}
|
||||||
|
value={Math.round(ratio * 100 * 10) / 10}
|
||||||
|
onChange={(v) => setColumnRatio(i, v / 100)}
|
||||||
|
min={5}
|
||||||
|
max={95}
|
||||||
|
precision={1}
|
||||||
|
step={1}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<span className="text-muted-foreground text-xs shrink-0">%</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<NumberInput
|
||||||
|
label="Col divider"
|
||||||
|
value={Math.round((node.columnDividerThickness ?? 0.03) * 1000) / 1000}
|
||||||
|
onChange={(v) => handleUpdate({ columnDividerThickness: v })}
|
||||||
|
min={0.005}
|
||||||
|
precision={3}
|
||||||
|
step={0.01}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<span className="text-muted-foreground text-xs shrink-0">m</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Row ratios */}
|
||||||
|
{numRows > 1 && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<span className="text-muted-foreground text-xs">Row heights</span>
|
||||||
|
{normRows.map((ratio, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-1.5">
|
||||||
|
<NumberInput
|
||||||
|
label={`R${i + 1}`}
|
||||||
|
value={Math.round(ratio * 100 * 10) / 10}
|
||||||
|
onChange={(v) => setRowRatio(i, v / 100)}
|
||||||
|
min={5}
|
||||||
|
max={95}
|
||||||
|
precision={1}
|
||||||
|
step={1}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<span className="text-muted-foreground text-xs shrink-0">%</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<NumberInput
|
||||||
|
label="Row divider"
|
||||||
|
value={Math.round((node.rowDividerThickness ?? 0.03) * 1000) / 1000}
|
||||||
|
onChange={(v) => handleUpdate({ rowDividerThickness: v })}
|
||||||
|
min={0.005}
|
||||||
|
precision={3}
|
||||||
|
step={0.01}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<span className="text-muted-foreground text-xs shrink-0">m</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sill */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<label className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
|
||||||
|
Sill
|
||||||
|
</label>
|
||||||
|
<Switch
|
||||||
|
checked={node.sill}
|
||||||
|
onCheckedChange={(checked) => handleUpdate({ sill: checked })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{node.sill && (
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<NumberInput
|
||||||
|
label="Depth"
|
||||||
|
value={Math.round(node.sillDepth * 1000) / 1000}
|
||||||
|
onChange={(v) => handleUpdate({ sillDepth: v })}
|
||||||
|
min={0.01}
|
||||||
|
precision={3}
|
||||||
|
step={0.01}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<span className="text-muted-foreground text-xs shrink-0">m</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<NumberInput
|
||||||
|
label="Thickness"
|
||||||
|
value={Math.round(node.sillThickness * 1000) / 1000}
|
||||||
|
onChange={(v) => handleUpdate({ sillThickness: v })}
|
||||||
|
min={0.005}
|
||||||
|
precision={3}
|
||||||
|
step={0.01}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<span className="text-muted-foreground text-xs shrink-0">m</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ interface NumberInputProps {
|
|||||||
min?: number
|
min?: number
|
||||||
max?: number
|
max?: number
|
||||||
precision?: number
|
precision?: number
|
||||||
|
step?: number
|
||||||
className?: string
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -20,6 +21,7 @@ export function NumberInput({
|
|||||||
min,
|
min,
|
||||||
max,
|
max,
|
||||||
precision = 2,
|
precision = 2,
|
||||||
|
step = 0.1,
|
||||||
className = '',
|
className = '',
|
||||||
}: NumberInputProps) {
|
}: NumberInputProps) {
|
||||||
const [isEditing, setIsEditing] = useState(false)
|
const [isEditing, setIsEditing] = useState(false)
|
||||||
@@ -55,14 +57,14 @@ export function NumberInput({
|
|||||||
const deltaX = moveEvent.clientX - startXRef.current
|
const deltaX = moveEvent.clientX - startXRef.current
|
||||||
|
|
||||||
// Determine step size based on modifier keys
|
// Determine step size based on modifier keys
|
||||||
let step = 0.1 // Default
|
let dragStep = step // Default from prop
|
||||||
if (moveEvent.shiftKey) {
|
if (moveEvent.shiftKey) {
|
||||||
step = 1.0 // Coarse
|
dragStep = step * 10 // Coarse
|
||||||
} else if (moveEvent.altKey) {
|
} else if (moveEvent.altKey) {
|
||||||
step = 0.01 // Fine
|
dragStep = step * 0.1 // Fine
|
||||||
}
|
}
|
||||||
|
|
||||||
const deltaValue = deltaX * step
|
const deltaValue = deltaX * dragStep
|
||||||
const newValue = clamp(startValueRef.current + deltaValue)
|
const newValue = clamp(startValueRef.current + deltaValue)
|
||||||
const newFinalValue = Number.parseFloat(newValue.toFixed(precision))
|
const newFinalValue = Number.parseFloat(newValue.toFixed(precision))
|
||||||
|
|
||||||
@@ -143,7 +145,8 @@ export function NumberInput({
|
|||||||
{isEditing ? (
|
{isEditing ? (
|
||||||
<input
|
<input
|
||||||
autoFocus
|
autoFocus
|
||||||
className="flex-1 bg-transparent px-2 py-1 text-foreground text-sm outline-none text-right"
|
size={1}
|
||||||
|
className="flex-1 min-w-0 bg-transparent px-2 py-1 text-foreground text-sm outline-none text-right"
|
||||||
onBlur={handleInputBlur}
|
onBlur={handleInputBlur}
|
||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
onKeyDown={handleInputKeyDown}
|
onKeyDown={handleInputKeyDown}
|
||||||
|
|||||||
@@ -71,15 +71,9 @@ export function SettingsPanel() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleGenerateThumbnail = () => {
|
const handleGenerateThumbnail = () => {
|
||||||
if (!projectId) {
|
if (!projectId) return;
|
||||||
console.error('❌ No project ID found');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
console.log('🎯 Generate thumbnail clicked for project:', projectId);
|
|
||||||
setIsGeneratingThumbnail(true);
|
setIsGeneratingThumbnail(true);
|
||||||
emitter.emit('camera-controls:generate-thumbnail', { projectId });
|
emitter.emit('camera-controls:generate-thumbnail', { projectId });
|
||||||
console.log('📤 Event emitted with project ID:', projectId);
|
|
||||||
// Reset loading state after a delay (thumbnail generation is async)
|
|
||||||
setTimeout(() => setIsGeneratingThumbnail(false), 3000);
|
setTimeout(() => setIsGeneratingThumbnail(false), 3000);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -106,6 +100,16 @@ export function SettingsPanel() {
|
|||||||
<label className="font-medium text-muted-foreground text-xs uppercase">
|
<label className="font-medium text-muted-foreground text-xs uppercase">
|
||||||
Thumbnail
|
Thumbnail
|
||||||
</label>
|
</label>
|
||||||
|
{activeProject?.thumbnail_url && (
|
||||||
|
<div className="rounded overflow-hidden border border-border aspect-video w-full bg-muted">
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
src={activeProject.thumbnail_url}
|
||||||
|
alt="Project thumbnail"
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
className="w-full justify-start gap-2"
|
className="w-full justify-start gap-2"
|
||||||
onClick={handleGenerateThumbnail}
|
onClick={handleGenerateThumbnail}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { ItemNode } from "@pascal-app/core";
|
import { type AnyNodeId, ItemNode } from "@pascal-app/core";
|
||||||
import { useViewer } from "@pascal-app/viewer";
|
import { useViewer } from "@pascal-app/viewer";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { RenamePopover } from "./rename-popover";
|
import { RenamePopover } from "./rename-popover";
|
||||||
import { TreeNodeWrapper } from "./tree-node";
|
import { TreeNode, TreeNodeWrapper } from "./tree-node";
|
||||||
import { TreeNodeActions } from "./tree-node-actions";
|
import { TreeNodeActions } from "./tree-node-actions";
|
||||||
|
|
||||||
const CATEGORY_ICONS: Record<string, string> = {
|
const CATEGORY_ICONS: Record<string, string> = {
|
||||||
@@ -23,6 +23,7 @@ interface ItemTreeNodeProps {
|
|||||||
|
|
||||||
export function ItemTreeNode({ node, depth }: ItemTreeNodeProps) {
|
export function ItemTreeNode({ node, depth }: ItemTreeNodeProps) {
|
||||||
const [renameOpen, setRenameOpen] = useState(false);
|
const [renameOpen, setRenameOpen] = useState(false);
|
||||||
|
const [expanded, setExpanded] = useState(true);
|
||||||
const iconSrc = CATEGORY_ICONS[node.asset.category] || "/icons/couch.png";
|
const iconSrc = CATEGORY_ICONS[node.asset.category] || "/icons/couch.png";
|
||||||
const isSelected = useViewer((state) => state.selection.selectedIds.includes(node.id));
|
const isSelected = useViewer((state) => state.selection.selectedIds.includes(node.id));
|
||||||
const isHovered = useViewer((state) => state.hoveredId === node.id);
|
const isHovered = useViewer((state) => state.hoveredId === node.id);
|
||||||
@@ -46,6 +47,7 @@ export function ItemTreeNode({ node, depth }: ItemTreeNodeProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const defaultName = node.asset.name || "Item";
|
const defaultName = node.asset.name || "Item";
|
||||||
|
const hasChildren = node.children && node.children.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<RenamePopover
|
<RenamePopover
|
||||||
@@ -58,9 +60,9 @@ export function ItemTreeNode({ node, depth }: ItemTreeNodeProps) {
|
|||||||
icon={<Image src={iconSrc} alt="" width={14} height={14} className="object-contain" />}
|
icon={<Image src={iconSrc} alt="" width={14} height={14} className="object-contain" />}
|
||||||
label={node.name || defaultName}
|
label={node.name || defaultName}
|
||||||
depth={depth}
|
depth={depth}
|
||||||
hasChildren={false}
|
hasChildren={hasChildren}
|
||||||
expanded={false}
|
expanded={expanded}
|
||||||
onToggle={() => {}}
|
onToggle={() => setExpanded(!expanded)}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
onDoubleClick={handleDoubleClick}
|
onDoubleClick={handleDoubleClick}
|
||||||
onMouseEnter={handleMouseEnter}
|
onMouseEnter={handleMouseEnter}
|
||||||
@@ -69,7 +71,11 @@ export function ItemTreeNode({ node, depth }: ItemTreeNodeProps) {
|
|||||||
isHovered={isHovered}
|
isHovered={isHovered}
|
||||||
isVisible={node.visible !== false}
|
isVisible={node.visible !== false}
|
||||||
actions={<TreeNodeActions node={node} />}
|
actions={<TreeNodeActions node={node} />}
|
||||||
/>
|
>
|
||||||
|
{hasChildren && node.children.map((childId) => (
|
||||||
|
<TreeNode key={childId} nodeId={childId} depth={depth + 1} />
|
||||||
|
))}
|
||||||
|
</TreeNodeWrapper>
|
||||||
</RenamePopover>
|
</RenamePopover>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -135,12 +135,17 @@ export default function CommunityHub() {
|
|||||||
|
|
||||||
<main className="container mx-auto px-6 py-8 space-y-12">
|
<main className="container mx-auto px-6 py-8 space-y-12">
|
||||||
{/* User's Projects Section */}
|
{/* User's Projects Section */}
|
||||||
{isAuthenticated && (userProjects.length > 0 || localProjects.length > 0) && (
|
{isAuthenticated && (
|
||||||
<section>
|
<section>
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<h2 className="text-xl font-semibold">My Projects</h2>
|
<h2 className="text-xl font-semibold">My Projects</h2>
|
||||||
<CreateProjectButton onCreateProject={handleCreateProject} />
|
<CreateProjectButton onCreateProject={handleCreateProject} />
|
||||||
</div>
|
</div>
|
||||||
|
{userProjects.length === 0 && localProjects.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed border-border py-16 text-center">
|
||||||
|
<p className="text-muted-foreground">You don't have any projects yet.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<ProjectGrid
|
<ProjectGrid
|
||||||
projects={[...userProjects, ...localProjects]}
|
projects={[...userProjects, ...localProjects]}
|
||||||
onProjectClick={handleProjectClick}
|
onProjectClick={handleProjectClick}
|
||||||
@@ -149,7 +154,6 @@ export default function CommunityHub() {
|
|||||||
showOwner={false}
|
showOwner={false}
|
||||||
canEdit
|
canEdit
|
||||||
onUpdate={() => {
|
onUpdate={() => {
|
||||||
// Reload projects after settings update
|
|
||||||
if (!authLoading) {
|
if (!authLoading) {
|
||||||
getUserProjects().then((result) => {
|
getUserProjects().then((result) => {
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
@@ -159,6 +163,7 @@ export default function CommunityHub() {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { useState } from 'react'
|
|||||||
import { createProject } from '../lib/projects/actions'
|
import { createProject } from '../lib/projects/actions'
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/primitives/dialog'
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/primitives/dialog'
|
||||||
import { Switch } from '@/components/ui/primitives/switch'
|
import { Switch } from '@/components/ui/primitives/switch'
|
||||||
import { GoogleAddressSearch } from './google-address-search'
|
|
||||||
|
|
||||||
interface NewProjectDialogProps {
|
interface NewProjectDialogProps {
|
||||||
open: boolean
|
open: boolean
|
||||||
@@ -18,38 +17,20 @@ interface NewProjectDialogProps {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AddressData {
|
|
||||||
streetNumber?: string
|
|
||||||
route?: string
|
|
||||||
city?: string
|
|
||||||
state?: string
|
|
||||||
postalCode?: string
|
|
||||||
country?: string
|
|
||||||
center: [number, number]
|
|
||||||
formattedAddress: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* NewProjectDialog - Dialog for creating a new project with optional Google Maps address search
|
* NewProjectDialog - Dialog for creating a new project with optional Google Maps address search
|
||||||
*/
|
*/
|
||||||
export function NewProjectDialog({ open, onOpenChange, onSuccess, localProjectData }: NewProjectDialogProps) {
|
export function NewProjectDialog({ open, onOpenChange, onSuccess, localProjectData }: NewProjectDialogProps) {
|
||||||
const [projectName, setProjectName] = useState(localProjectData?.name || '')
|
const [projectName, setProjectName] = useState(localProjectData?.name || '')
|
||||||
const [address, setAddress] = useState<AddressData | null>(null)
|
|
||||||
const [showAddressSearch, setShowAddressSearch] = useState(false)
|
|
||||||
const [isPrivate, setIsPrivate] = useState(false)
|
const [isPrivate, setIsPrivate] = useState(false)
|
||||||
const [isCreating, setIsCreating] = useState(false)
|
const [isCreating, setIsCreating] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
const handleAddressSelect = (addressData: AddressData) => {
|
|
||||||
setAddress(addressData)
|
|
||||||
setError(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setError(null)
|
setError(null)
|
||||||
|
|
||||||
const name = projectName.trim() || (address?.formattedAddress ?? 'Untitled Project')
|
const name = projectName.trim() || 'Untitled Project'
|
||||||
|
|
||||||
if (!name) {
|
if (!name) {
|
||||||
setError('Please enter a project name')
|
setError('Please enter a project name')
|
||||||
@@ -61,13 +42,6 @@ export function NewProjectDialog({ open, onOpenChange, onSuccess, localProjectDa
|
|||||||
try {
|
try {
|
||||||
const result = await createProject({
|
const result = await createProject({
|
||||||
name,
|
name,
|
||||||
center: address?.center,
|
|
||||||
streetNumber: address?.streetNumber,
|
|
||||||
route: address?.route,
|
|
||||||
city: address?.city,
|
|
||||||
state: address?.state,
|
|
||||||
postalCode: address?.postalCode,
|
|
||||||
country: address?.country || 'US',
|
|
||||||
isPrivate,
|
isPrivate,
|
||||||
sceneGraph: localProjectData?.sceneGraph,
|
sceneGraph: localProjectData?.sceneGraph,
|
||||||
})
|
})
|
||||||
@@ -75,8 +49,6 @@ export function NewProjectDialog({ open, onOpenChange, onSuccess, localProjectDa
|
|||||||
if (result.success && result.data) {
|
if (result.success && result.data) {
|
||||||
onOpenChange(false)
|
onOpenChange(false)
|
||||||
setProjectName('')
|
setProjectName('')
|
||||||
setAddress(null)
|
|
||||||
setShowAddressSearch(false)
|
|
||||||
setIsPrivate(false)
|
setIsPrivate(false)
|
||||||
onSuccess?.(result.data.id)
|
onSuccess?.(result.data.id)
|
||||||
} else {
|
} else {
|
||||||
@@ -93,8 +65,6 @@ export function NewProjectDialog({ open, onOpenChange, onSuccess, localProjectDa
|
|||||||
if (!isCreating) {
|
if (!isCreating) {
|
||||||
onOpenChange(false)
|
onOpenChange(false)
|
||||||
setProjectName('')
|
setProjectName('')
|
||||||
setAddress(null)
|
|
||||||
setShowAddressSearch(false)
|
|
||||||
setIsPrivate(false)
|
setIsPrivate(false)
|
||||||
setError(null)
|
setError(null)
|
||||||
}
|
}
|
||||||
@@ -136,44 +106,6 @@ export function NewProjectDialog({ open, onOpenChange, onSuccess, localProjectDa
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Optional Address Section */}
|
|
||||||
{!showAddressSearch ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowAddressSearch(true)}
|
|
||||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
|
||||||
disabled={isCreating}
|
|
||||||
>
|
|
||||||
+ Add an address (optional)
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<label className="text-sm font-medium">Address (optional)</label>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
setShowAddressSearch(false)
|
|
||||||
setAddress(null)
|
|
||||||
}}
|
|
||||||
className="text-xs text-muted-foreground hover:text-foreground"
|
|
||||||
disabled={isCreating}
|
|
||||||
>
|
|
||||||
Remove
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<GoogleAddressSearch onAddressSelect={handleAddressSelect} disabled={isCreating} />
|
|
||||||
|
|
||||||
{/* Show selected address */}
|
|
||||||
{address && (
|
|
||||||
<div className="rounded-md border border-border bg-muted/30 p-3 text-sm">
|
|
||||||
<p className="font-medium">Selected Address:</p>
|
|
||||||
<p className="mt-1 text-muted-foreground">{address.formattedAddress}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Privacy Toggle */}
|
{/* Privacy Toggle */}
|
||||||
<div className="flex items-center justify-between rounded-md border border-border p-3">
|
<div className="flex items-center justify-between rounded-md border border-border p-3">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
'use server'
|
||||||
|
|
||||||
|
import { createId } from '@pascal-app/db'
|
||||||
|
import { createServerSupabaseClient } from '../database/server'
|
||||||
|
import { getSession } from '../auth/server'
|
||||||
|
|
||||||
|
export async function submitFeedback(
|
||||||
|
message: string,
|
||||||
|
): Promise<{ success: true } | { success: false; error: string }> {
|
||||||
|
try {
|
||||||
|
const trimmed = message.trim()
|
||||||
|
if (!trimmed) return { success: false, error: 'Message cannot be empty' }
|
||||||
|
|
||||||
|
const session = await getSession()
|
||||||
|
const supabase = await createServerSupabaseClient()
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const { error } = await (supabase as any).from('feedback').insert({
|
||||||
|
id: createId('feedback'),
|
||||||
|
user_id: session?.user?.id ?? null,
|
||||||
|
message: trimmed,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (error) return { success: false, error: error.message }
|
||||||
|
|
||||||
|
return { success: true }
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: err instanceof Error ? err.message : 'Failed to submit feedback',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1020,16 +1020,14 @@ export async function uploadProjectThumbnail(
|
|||||||
return { success: false, error: 'Not authorized to update this project' }
|
return { success: false, error: 'Not authorized to update this project' }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate a unique filename
|
const filename = `${projectId}/thumbnail.png`
|
||||||
const timestamp = Date.now()
|
|
||||||
const filename = `${projectId}/${timestamp}.png`
|
|
||||||
|
|
||||||
// Upload to Supabase Storage
|
// Upload to Supabase Storage (upsert to override existing thumbnail)
|
||||||
const { data: uploadData, error: uploadError } = await supabase.storage
|
const { data: uploadData, error: uploadError } = await supabase.storage
|
||||||
.from('project-thumbnails')
|
.from('project-thumbnails')
|
||||||
.upload(filename, blob, {
|
.upload(filename, blob, {
|
||||||
contentType: 'image/png',
|
contentType: 'image/png',
|
||||||
upsert: false,
|
upsert: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
if (uploadError) {
|
if (uploadError) {
|
||||||
@@ -1041,7 +1039,7 @@ export async function uploadProjectThumbnail(
|
|||||||
.from('project-thumbnails')
|
.from('project-thumbnails')
|
||||||
.getPublicUrl(uploadData.path)
|
.getPublicUrl(uploadData.path)
|
||||||
|
|
||||||
const thumbnailUrl = urlData.publicUrl
|
const thumbnailUrl = `${urlData.publicUrl}?t=${Date.now()}`
|
||||||
|
|
||||||
// Update the project with the new thumbnail URL
|
// Update the project with the new thumbnail URL
|
||||||
const { error: updateError } = await (supabase
|
const { error: updateError } = await (supabase
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ interface ProjectStore {
|
|||||||
fetchActiveProject: () => Promise<void>
|
fetchActiveProject: () => Promise<void>
|
||||||
setActiveProject: (projectId: string) => Promise<void>
|
setActiveProject: (projectId: string) => Promise<void>
|
||||||
initialize: () => Promise<void>
|
initialize: () => Promise<void>
|
||||||
|
updateActiveThumbnail: (thumbnailUrl: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useProjectStore = create<ProjectStore>((set, get) => ({
|
export const useProjectStore = create<ProjectStore>((set, get) => ({
|
||||||
@@ -78,6 +79,15 @@ export const useProjectStore = create<ProjectStore>((set, get) => ({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Patch the active project's thumbnail URL in place (no refetch)
|
||||||
|
updateActiveThumbnail: (thumbnailUrl: string) => {
|
||||||
|
set((state) => ({
|
||||||
|
activeProject: state.activeProject
|
||||||
|
? { ...state.activeProject, thumbnail_url: thumbnailUrl }
|
||||||
|
: null,
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
|
||||||
// Initialize - fetch both projects and active project
|
// Initialize - fetch both projects and active project
|
||||||
initialize: async () => {
|
initialize: async () => {
|
||||||
set({ isLoading: true })
|
set({ isLoading: true })
|
||||||
|
|||||||
@@ -2,6 +2,11 @@ import type { NextConfig } from 'next'
|
|||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
transpilePackages: ['three', '@pascal-app/viewer', '@pascal-app/core'],
|
transpilePackages: ['three', '@pascal-app/viewer', '@pascal-app/core'],
|
||||||
|
experimental: {
|
||||||
|
serverActions: {
|
||||||
|
bodySizeLimit: '10mb',
|
||||||
|
},
|
||||||
|
},
|
||||||
images: {
|
images: {
|
||||||
remotePatterns: [
|
remotePatterns: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export type {
|
|||||||
SiteEvent,
|
SiteEvent,
|
||||||
SlabEvent,
|
SlabEvent,
|
||||||
WallEvent,
|
WallEvent,
|
||||||
|
WindowEvent,
|
||||||
ZoneEvent,
|
ZoneEvent,
|
||||||
} from './events/bus'
|
} from './events/bus'
|
||||||
// Events
|
// Events
|
||||||
@@ -22,12 +23,21 @@ export {
|
|||||||
sceneRegistry,
|
sceneRegistry,
|
||||||
useRegistry,
|
useRegistry,
|
||||||
} from './hooks/scene-registry/scene-registry'
|
} from './hooks/scene-registry/scene-registry'
|
||||||
|
export { pointInPolygon, spatialGridManager } from './hooks/spatial-grid/spatial-grid-manager'
|
||||||
export {
|
export {
|
||||||
initSpatialGridSync,
|
initSpatialGridSync,
|
||||||
resolveLevelId,
|
resolveLevelId,
|
||||||
} from './hooks/spatial-grid/spatial-grid-sync'
|
} from './hooks/spatial-grid/spatial-grid-sync'
|
||||||
export { useSpatialQuery } from './hooks/spatial-grid/use-spatial-query'
|
export { useSpatialQuery } from './hooks/spatial-grid/use-spatial-query'
|
||||||
export { pointInPolygon, spatialGridManager } from './hooks/spatial-grid/spatial-grid-manager'
|
// Asset storage
|
||||||
|
export { loadAssetUrl, saveAsset } from './lib/asset-storage'
|
||||||
|
// Space detection
|
||||||
|
export {
|
||||||
|
detectSpacesForLevel,
|
||||||
|
initSpaceDetectionSync,
|
||||||
|
type Space,
|
||||||
|
wallTouchesOthers,
|
||||||
|
} from './lib/space-detection'
|
||||||
// Schema
|
// Schema
|
||||||
export * from './schema'
|
export * from './schema'
|
||||||
export { default as useScene } from './store/use-scene'
|
export { default as useScene } from './store/use-scene'
|
||||||
@@ -38,9 +48,4 @@ export { RoofSystem } from './systems/roof/roof-system'
|
|||||||
export { SlabSystem } from './systems/slab/slab-system'
|
export { SlabSystem } from './systems/slab/slab-system'
|
||||||
export { WallSystem } from './systems/wall/wall-system'
|
export { WallSystem } from './systems/wall/wall-system'
|
||||||
export { WindowSystem } from './systems/window/window-system'
|
export { WindowSystem } from './systems/window/window-system'
|
||||||
|
|
||||||
export { isObject } from './utils/types'
|
export { isObject } from './utils/types'
|
||||||
// Asset storage
|
|
||||||
export { saveAsset, loadAssetUrl } from './lib/asset-storage'
|
|
||||||
// Space detection
|
|
||||||
export { detectSpacesForLevel, wallTouchesOthers, initSpaceDetectionSync, type Space } from './lib/space-detection'
|
|
||||||
|
|||||||
@@ -15,6 +15,11 @@ const assetSchema = z.object({
|
|||||||
offset: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
|
offset: 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]),
|
rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
|
||||||
scale: z.tuple([z.number(), z.number(), z.number()]).default([1, 1, 1]),
|
scale: z.tuple([z.number(), z.number(), z.number()]).default([1, 1, 1]),
|
||||||
|
surface: z
|
||||||
|
.object({
|
||||||
|
height: z.number(), // where things rest
|
||||||
|
})
|
||||||
|
.optional(), // undefined = can't place things on it
|
||||||
})
|
})
|
||||||
|
|
||||||
export type AssetInput = z.input<typeof assetSchema>
|
export type AssetInput = z.input<typeof assetSchema>
|
||||||
@@ -26,6 +31,7 @@ export const ItemNode = BaseNode.extend({
|
|||||||
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
|
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]),
|
rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
|
||||||
side: z.enum(['front', 'back']).optional(),
|
side: z.enum(['front', 'back']).optional(),
|
||||||
|
children: z.array(objectId('item')).default([]),
|
||||||
|
|
||||||
// Wall attachment properties (only used when asset.attachTo is "wall" or "wall-side")
|
// Wall attachment properties (only used when asset.attachTo is "wall" or "wall-side")
|
||||||
wallId: z.string().optional(),
|
wallId: z.string().optional(),
|
||||||
|
|||||||
@@ -28,7 +28,8 @@ export const WindowNode = BaseNode.extend({
|
|||||||
// [1] = single pane (no division)
|
// [1] = single pane (no division)
|
||||||
columnRatios: z.array(z.number()).default([1]),
|
columnRatios: z.array(z.number()).default([1]),
|
||||||
rowRatios: z.array(z.number()).default([1]),
|
rowRatios: z.array(z.number()).default([1]),
|
||||||
dividerThickness: z.number().default(0.03),
|
columnDividerThickness: z.number().default(0.03),
|
||||||
|
rowDividerThickness: z.number().default(0.03),
|
||||||
|
|
||||||
// Sill
|
// Sill
|
||||||
sill: z.boolean().default(true),
|
sill: z.boolean().default(true),
|
||||||
|
|||||||
@@ -35,6 +35,9 @@ export const ItemSystem = () => {
|
|||||||
mesh.position.z = (wallThickness / 2) * side;
|
mesh.position.z = (wallThickness / 2) * side;
|
||||||
}
|
}
|
||||||
} else if (!item.asset.attachTo) {
|
} else if (!item.asset.attachTo) {
|
||||||
|
// If parented to another item (surface placement), R3F handles positioning via the hierarchy
|
||||||
|
const parentNode = item.parentId ? nodes[item.parentId as AnyNodeId] : undefined
|
||||||
|
if (parentNode?.type !== 'item') {
|
||||||
// Floor item: elevate by slab height (using full footprint overlap)
|
// Floor item: elevate by slab height (using full footprint overlap)
|
||||||
const levelId = resolveLevelId(item, nodes)
|
const levelId = resolveLevelId(item, nodes)
|
||||||
const slabElevation = spatialGridManager.getSlabElevationForItem(
|
const slabElevation = spatialGridManager.getSlabElevationForItem(
|
||||||
@@ -43,7 +46,8 @@ export const ItemSystem = () => {
|
|||||||
item.asset.dimensions,
|
item.asset.dimensions,
|
||||||
item.rotation,
|
item.rotation,
|
||||||
)
|
)
|
||||||
mesh.position.y = slabElevation
|
mesh.position.y = slabElevation + item.position[1]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
clearDirty(id as AnyNodeId)
|
clearDirty(id as AnyNodeId)
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ function updateWallGeometry(wallId: string, miterData: WallMiterData) {
|
|||||||
collisionMesh.geometry = collisionGeo
|
collisionMesh.geometry = collisionGeo
|
||||||
}
|
}
|
||||||
|
|
||||||
mesh.position.set(node.start[0], 0, node.start[1])
|
mesh.position.set(node.start[0], slabElevation, node.start[1])
|
||||||
const angle = Math.atan2(node.end[1] - node.start[1], node.end[0] - node.start[0])
|
const angle = Math.atan2(node.end[1] - node.start[1], node.end[0] - node.start[0])
|
||||||
mesh.rotation.y = -angle
|
mesh.rotation.y = -angle
|
||||||
}
|
}
|
||||||
@@ -153,8 +153,10 @@ export function generateExtrudedWall(
|
|||||||
|
|
||||||
const wallStart: Point2D = { x: wallNode.start[0], y: wallNode.start[1] }
|
const wallStart: Point2D = { x: wallNode.start[0], y: wallNode.start[1] }
|
||||||
const wallEnd: Point2D = { x: wallNode.end[0], y: wallNode.end[1] }
|
const wallEnd: Point2D = { x: wallNode.end[0], y: wallNode.end[1] }
|
||||||
// Wall height is adjusted by slab elevation (positive reduces, negative increases)
|
// Positive slab: shift the whole wall up (full height preserved)
|
||||||
const height = (wallNode.height ?? 2.5) - slabElevation
|
// Negative slab: extend wall downward so top stays fixed at wallNode.height
|
||||||
|
const wallHeight = wallNode.height ?? 2.5
|
||||||
|
const height = slabElevation > 0 ? wallHeight : wallHeight - slabElevation
|
||||||
|
|
||||||
const thickness = wallNode.thickness ?? 0.1
|
const thickness = wallNode.thickness ?? 0.1
|
||||||
const halfT = thickness / 2
|
const halfT = thickness / 2
|
||||||
@@ -248,10 +250,6 @@ export function generateExtrudedWall(
|
|||||||
|
|
||||||
// Rotate so extrusion direction (Z) becomes height direction (Y)
|
// Rotate so extrusion direction (Z) becomes height direction (Y)
|
||||||
geometry.rotateX(-Math.PI / 2)
|
geometry.rotateX(-Math.PI / 2)
|
||||||
// Translate by slab elevation (works for both positive and negative values)
|
|
||||||
if (slabElevation !== 0) {
|
|
||||||
geometry.translate(0, slabElevation, 0)
|
|
||||||
}
|
|
||||||
geometry.computeVertexNormals()
|
geometry.computeVertexNormals()
|
||||||
|
|
||||||
// Apply CSG subtraction for cutouts (doors/windows)
|
// Apply CSG subtraction for cutouts (doors/windows)
|
||||||
|
|||||||
@@ -7,15 +7,25 @@ import useScene from '../../store/use-scene'
|
|||||||
|
|
||||||
const glassMaterial = new MeshStandardNodeMaterial({
|
const glassMaterial = new MeshStandardNodeMaterial({
|
||||||
name: 'glass',
|
name: 'glass',
|
||||||
color: 'lightgray',
|
color: 'lightblue',
|
||||||
roughness: 0.8,
|
roughness: 0.05,
|
||||||
metalness: 0,
|
metalness: 0.1,
|
||||||
transparent: true,
|
transparent: true,
|
||||||
opacity: 0.35,
|
opacity: 0.3,
|
||||||
side: DoubleSide,
|
side: DoubleSide,
|
||||||
depthWrite: false,
|
depthWrite: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const frameMaterial = new MeshStandardNodeMaterial({
|
||||||
|
name: 'window-frame',
|
||||||
|
color: '#e8e8e8',
|
||||||
|
roughness: 0.6,
|
||||||
|
metalness: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Invisible material for root mesh — used as selection hitbox only
|
||||||
|
const hitboxMaterial = new THREE.MeshBasicMaterial({ visible: false })
|
||||||
|
|
||||||
export const WindowSystem = () => {
|
export const WindowSystem = () => {
|
||||||
const dirtyNodes = useScene((state) => state.dirtyNodes)
|
const dirtyNodes = useScene((state) => state.dirtyNodes)
|
||||||
const clearDirty = useScene((state) => state.clearDirty)
|
const clearDirty = useScene((state) => state.clearDirty)
|
||||||
@@ -45,17 +55,117 @@ export const WindowSystem = () => {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function addBox(
|
||||||
|
parent: THREE.Object3D,
|
||||||
|
material: THREE.Material,
|
||||||
|
w: number, h: number, d: number,
|
||||||
|
x: number, y: number, z: number,
|
||||||
|
) {
|
||||||
|
const m = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), material)
|
||||||
|
m.position.set(x, y, z)
|
||||||
|
parent.add(m)
|
||||||
|
}
|
||||||
|
|
||||||
function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh) {
|
function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh) {
|
||||||
// Replace geometry with a box matching the overall window dimensions
|
// Root mesh is an invisible hitbox; all visuals live in child meshes
|
||||||
mesh.geometry.dispose()
|
mesh.geometry.dispose()
|
||||||
mesh.geometry = new THREE.BoxGeometry(node.width, node.height, node.frameDepth)
|
mesh.geometry = new THREE.BoxGeometry(node.width, node.height, node.frameDepth)
|
||||||
mesh.material = glassMaterial
|
mesh.material = hitboxMaterial
|
||||||
|
|
||||||
// Sync transform from node (React may lag behind the system by a frame during drag)
|
// Sync transform from node (React may lag behind the system by a frame during drag)
|
||||||
mesh.position.set(node.position[0], node.position[1], node.position[2])
|
mesh.position.set(node.position[0], node.position[1], node.position[2])
|
||||||
mesh.rotation.set(node.rotation[0], node.rotation[1], node.rotation[2])
|
mesh.rotation.set(node.rotation[0], node.rotation[1], node.rotation[2])
|
||||||
|
|
||||||
// Update (or create) the named cutout mesh used by wall-system for CSG subtraction
|
// Dispose and remove all old visual children; preserve 'cutout'
|
||||||
|
for (const child of [...mesh.children]) {
|
||||||
|
if (child.name === 'cutout') continue
|
||||||
|
if (child instanceof THREE.Mesh) child.geometry.dispose()
|
||||||
|
mesh.remove(child)
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
width, height, frameDepth, frameThickness,
|
||||||
|
columnRatios, rowRatios, columnDividerThickness, rowDividerThickness,
|
||||||
|
sill, sillDepth, sillThickness,
|
||||||
|
} = node
|
||||||
|
|
||||||
|
const innerW = width - 2 * frameThickness
|
||||||
|
const innerH = height - 2 * frameThickness
|
||||||
|
|
||||||
|
// ── Frame members ──
|
||||||
|
// Top / bottom — full width
|
||||||
|
addBox(mesh, frameMaterial, width, frameThickness, frameDepth, 0, height / 2 - frameThickness / 2, 0)
|
||||||
|
addBox(mesh, frameMaterial, width, frameThickness, frameDepth, 0, -height / 2 + frameThickness / 2, 0)
|
||||||
|
// Left / right — inner height to avoid corner overlap
|
||||||
|
addBox(mesh, frameMaterial, frameThickness, innerH, frameDepth, -width / 2 + frameThickness / 2, 0, 0)
|
||||||
|
addBox(mesh, frameMaterial, frameThickness, innerH, frameDepth, width / 2 - frameThickness / 2, 0, 0)
|
||||||
|
|
||||||
|
// ── Pane grid ──
|
||||||
|
const numCols = columnRatios.length
|
||||||
|
const numRows = rowRatios.length
|
||||||
|
|
||||||
|
const usableW = innerW - (numCols - 1) * columnDividerThickness
|
||||||
|
const usableH = innerH - (numRows - 1) * rowDividerThickness
|
||||||
|
|
||||||
|
const colSum = columnRatios.reduce((a, b) => a + b, 0)
|
||||||
|
const rowSum = rowRatios.reduce((a, b) => a + b, 0)
|
||||||
|
const colWidths = columnRatios.map(r => (r / colSum) * usableW)
|
||||||
|
const rowHeights = rowRatios.map(r => (r / rowSum) * usableH)
|
||||||
|
|
||||||
|
// Compute column x-centers starting from left edge of inner area
|
||||||
|
const colXCenters: number[] = []
|
||||||
|
let cx = -innerW / 2
|
||||||
|
for (let c = 0; c < numCols; c++) {
|
||||||
|
colXCenters.push(cx + colWidths[c]! / 2)
|
||||||
|
cx += colWidths[c]!
|
||||||
|
if (c < numCols - 1) cx += columnDividerThickness
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute row y-centers starting from top edge of inner area (R1 = top)
|
||||||
|
const rowYCenters: number[] = []
|
||||||
|
let cy = innerH / 2
|
||||||
|
for (let r = 0; r < numRows; r++) {
|
||||||
|
rowYCenters.push(cy - rowHeights[r]! / 2)
|
||||||
|
cy -= rowHeights[r]!
|
||||||
|
if (r < numRows - 1) cy -= rowDividerThickness
|
||||||
|
}
|
||||||
|
|
||||||
|
// Column dividers — full inner height
|
||||||
|
cx = -innerW / 2
|
||||||
|
for (let c = 0; c < numCols - 1; c++) {
|
||||||
|
cx += colWidths[c]!
|
||||||
|
addBox(mesh, frameMaterial, columnDividerThickness, innerH, frameDepth, cx + columnDividerThickness / 2, 0, 0)
|
||||||
|
cx += columnDividerThickness
|
||||||
|
}
|
||||||
|
|
||||||
|
// Row dividers — per column width, so they don't overlap column dividers (top to bottom)
|
||||||
|
cy = innerH / 2
|
||||||
|
for (let r = 0; r < numRows - 1; r++) {
|
||||||
|
cy -= rowHeights[r]!
|
||||||
|
const divY = cy - rowDividerThickness / 2
|
||||||
|
for (let c = 0; c < numCols; c++) {
|
||||||
|
addBox(mesh, frameMaterial, colWidths[c]!, rowDividerThickness, frameDepth, colXCenters[c]!, divY, 0)
|
||||||
|
}
|
||||||
|
cy -= rowDividerThickness
|
||||||
|
}
|
||||||
|
|
||||||
|
// Glass panes
|
||||||
|
const glassDepth = Math.max(0.004, frameDepth * 0.08)
|
||||||
|
for (let c = 0; c < numCols; c++) {
|
||||||
|
for (let r = 0; r < numRows; r++) {
|
||||||
|
addBox(mesh, glassMaterial, colWidths[c]!, rowHeights[r]!, glassDepth, colXCenters[c]!, rowYCenters[r]!, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Sill ──
|
||||||
|
if (sill) {
|
||||||
|
const sillW = width + sillDepth * 0.4 // slightly wider than frame
|
||||||
|
// Protrudes from the front face of the frame (+Z)
|
||||||
|
const sillZ = frameDepth / 2 + sillDepth / 2
|
||||||
|
addBox(mesh, frameMaterial, sillW, sillThickness, sillDepth, 0, -height / 2 - sillThickness / 2, sillZ)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Cutout (for wall CSG) — always full window dimensions, 1m deep ──
|
||||||
let cutout = mesh.getObjectByName('cutout') as THREE.Mesh | undefined
|
let cutout = mesh.getObjectByName('cutout') as THREE.Mesh | undefined
|
||||||
if (!cutout) {
|
if (!cutout) {
|
||||||
cutout = new THREE.Mesh()
|
cutout = new THREE.Mesh()
|
||||||
@@ -63,7 +173,6 @@ function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh) {
|
|||||||
mesh.add(cutout)
|
mesh.add(cutout)
|
||||||
}
|
}
|
||||||
cutout.geometry.dispose()
|
cutout.geometry.dispose()
|
||||||
// Extends 1m through the wall so the CSG brush covers full wall thickness
|
|
||||||
cutout.geometry = new THREE.BoxGeometry(node.width, node.height, 1.0)
|
cutout.geometry = new THREE.BoxGeometry(node.width, node.height, 1.0)
|
||||||
cutout.visible = false;
|
cutout.visible = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { pgTable } from 'drizzle-orm/pg-core'
|
||||||
|
import { createInsertSchema, createSelectSchema } from 'drizzle-zod'
|
||||||
|
import { id, createdAt } from '../../helpers'
|
||||||
|
|
||||||
|
export const feedback = pgTable('feedback', (t) => ({
|
||||||
|
id: id('feedback'),
|
||||||
|
userId: t.text('user_id'), // nullable — stores Better Auth user ID or null for anonymous
|
||||||
|
message: t.text('message').notNull(),
|
||||||
|
createdAt,
|
||||||
|
})).enableRLS()
|
||||||
|
|
||||||
|
export type Feedback = typeof feedback.$inferSelect
|
||||||
|
export type NewFeedback = typeof feedback.$inferInsert
|
||||||
|
export const insertFeedbackSchema = createInsertSchema(feedback)
|
||||||
|
export const selectFeedbackSchema = createSelectSchema(feedback)
|
||||||
@@ -5,6 +5,9 @@ export * from './auth/sessions'
|
|||||||
export * from './auth/users'
|
export * from './auth/users'
|
||||||
export * from './auth/verifications'
|
export * from './auth/verifications'
|
||||||
|
|
||||||
|
// Feedback table
|
||||||
|
export * from './feedback/feedback'
|
||||||
|
|
||||||
// Project tables
|
// Project tables
|
||||||
export * from './projects/addresses'
|
export * from './projects/addresses'
|
||||||
export * from './projects/likes'
|
export * from './projects/likes'
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
-- Create feedback table
|
||||||
|
CREATE TABLE IF NOT EXISTS feedback (
|
||||||
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
|
user_id TEXT,
|
||||||
|
message TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Enable Row Level Security
|
||||||
|
ALTER TABLE feedback ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
-- Allow anyone (authenticated or anonymous) to submit feedback
|
||||||
|
CREATE POLICY "Anyone can insert feedback"
|
||||||
|
ON feedback
|
||||||
|
FOR INSERT
|
||||||
|
TO anon, authenticated
|
||||||
|
WITH CHECK (true);
|
||||||
|
|
||||||
|
-- Allow service role full access (for admin review)
|
||||||
|
CREATE POLICY "Service role full access"
|
||||||
|
ON feedback
|
||||||
|
TO service_role
|
||||||
|
USING (true)
|
||||||
|
WITH CHECK (true);
|
||||||
@@ -7,6 +7,7 @@ import { positionLocal, smoothstep, time } from 'three/tsl'
|
|||||||
import { DoubleSide, MeshStandardNodeMaterial } from 'three/webgpu'
|
import { DoubleSide, MeshStandardNodeMaterial } from 'three/webgpu'
|
||||||
import { useNodeEvents } from '../../../hooks/use-node-events'
|
import { useNodeEvents } from '../../../hooks/use-node-events'
|
||||||
import { resolveCdnUrl } from '../../../lib/asset-url'
|
import { resolveCdnUrl } from '../../../lib/asset-url'
|
||||||
|
import { NodeRenderer } from '../node-renderer'
|
||||||
|
|
||||||
// Shared materials to avoid creating new instances for every mesh
|
// Shared materials to avoid creating new instances for every mesh
|
||||||
const defaultMaterial = new MeshStandardNodeMaterial({
|
const defaultMaterial = new MeshStandardNodeMaterial({
|
||||||
@@ -43,6 +44,9 @@ export const ItemRenderer = ({ node }: { node: ItemNode }) => {
|
|||||||
<Suspense fallback={<PreviewModel node={node} />}>
|
<Suspense fallback={<PreviewModel node={node} />}>
|
||||||
<ModelRenderer node={node} />
|
<ModelRenderer node={node} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
{node.children?.map((childId) => (
|
||||||
|
<NodeRenderer key={childId} nodeId={childId } />
|
||||||
|
))}
|
||||||
</group>
|
</group>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { useRegistry, type WindowNode } from '@pascal-app/core'
|
import { useRegistry, type WindowNode } from '@pascal-app/core'
|
||||||
import { useRef } from 'react'
|
import { useRef } from 'react'
|
||||||
import type { Mesh } from 'three'
|
import type { Mesh } from 'three'
|
||||||
|
import { useNodeEvents } from '../../../hooks/use-node-events'
|
||||||
|
|
||||||
export const WindowRenderer = ({ node }: { node: WindowNode }) => {
|
export const WindowRenderer = ({ node }: { node: WindowNode }) => {
|
||||||
const ref = useRef<Mesh>(null!)
|
const ref = useRef<Mesh>(null!)
|
||||||
|
|
||||||
useRegistry(node.id, 'window', ref)
|
useRegistry(node.id, 'window', ref)
|
||||||
|
const handlers = useNodeEvents(node, 'window')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<mesh
|
<mesh
|
||||||
@@ -15,6 +17,7 @@ export const WindowRenderer = ({ node }: { node: WindowNode }) => {
|
|||||||
visible={node.visible}
|
visible={node.visible}
|
||||||
position={node.position}
|
position={node.position}
|
||||||
rotation={node.rotation}
|
rotation={node.rotation}
|
||||||
|
{...handlers}
|
||||||
>
|
>
|
||||||
{/* WindowSystem replaces this geometry each time the node is dirty */}
|
{/* WindowSystem replaces this geometry each time the node is dirty */}
|
||||||
<boxGeometry args={[0, 0, 0]} />
|
<boxGeometry args={[0, 0, 0]} />
|
||||||
|
|||||||
@@ -17,11 +17,13 @@ import {
|
|||||||
type SlabNode,
|
type SlabNode,
|
||||||
type WallEvent,
|
type WallEvent,
|
||||||
type WallNode,
|
type WallNode,
|
||||||
|
type WindowEvent,
|
||||||
|
type WindowNode,
|
||||||
type ZoneEvent,
|
type ZoneEvent,
|
||||||
type ZoneNode,
|
type ZoneNode,
|
||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
import type { ThreeEvent } from '@react-three/fiber'
|
import type { ThreeEvent } from '@react-three/fiber'
|
||||||
import useViewer from '../store/use-viewer';
|
import useViewer from '../store/use-viewer'
|
||||||
|
|
||||||
type NodeConfig = {
|
type NodeConfig = {
|
||||||
site: { node: SiteNode; event: SiteEvent }
|
site: { node: SiteNode; event: SiteEvent }
|
||||||
@@ -33,6 +35,7 @@ type NodeConfig = {
|
|||||||
slab: { node: SlabNode; event: SlabEvent }
|
slab: { node: SlabNode; event: SlabEvent }
|
||||||
ceiling: { node: CeilingNode; event: CeilingEvent }
|
ceiling: { node: CeilingNode; event: CeilingEvent }
|
||||||
roof: { node: RoofNode; event: RoofEvent }
|
roof: { node: RoofNode; event: RoofEvent }
|
||||||
|
window: { node: WindowNode; event: WindowEvent }
|
||||||
}
|
}
|
||||||
|
|
||||||
type NodeType = keyof NodeConfig
|
type NodeType = keyof NodeConfig
|
||||||
@@ -69,10 +72,25 @@ export function useNodeEvents<T extends NodeType>(node: NodeConfig[T]['node'], t
|
|||||||
if (e.button !== 0) return
|
if (e.button !== 0) return
|
||||||
emit('click', e)
|
emit('click', e)
|
||||||
},
|
},
|
||||||
onPointerEnter: (e: ThreeEvent<PointerEvent>) => {if (useViewer.getState().cameraDragging) return; emit('enter', e)},
|
onPointerEnter: (e: ThreeEvent<PointerEvent>) => {
|
||||||
onPointerLeave: (e: ThreeEvent<PointerEvent>) => {if (useViewer.getState().cameraDragging) return; emit('leave', e)},
|
if (useViewer.getState().cameraDragging) return
|
||||||
onPointerMove: (e: ThreeEvent<PointerEvent>) => {if (useViewer.getState().cameraDragging) return; emit('move', e)},
|
emit('enter', e)
|
||||||
onDoubleClick: (e: ThreeEvent<PointerEvent>) => {if (useViewer.getState().cameraDragging) return; emit('double-click', e)},
|
},
|
||||||
onContextMenu: (e: ThreeEvent<PointerEvent>) => {if (useViewer.getState().cameraDragging) return; emit('context-menu', e)},
|
onPointerLeave: (e: ThreeEvent<PointerEvent>) => {
|
||||||
|
if (useViewer.getState().cameraDragging) return
|
||||||
|
emit('leave', e)
|
||||||
|
},
|
||||||
|
onPointerMove: (e: ThreeEvent<PointerEvent>) => {
|
||||||
|
if (useViewer.getState().cameraDragging) return
|
||||||
|
emit('move', e)
|
||||||
|
},
|
||||||
|
onDoubleClick: (e: ThreeEvent<PointerEvent>) => {
|
||||||
|
if (useViewer.getState().cameraDragging) return
|
||||||
|
emit('double-click', e)
|
||||||
|
},
|
||||||
|
onContextMenu: (e: ThreeEvent<PointerEvent>) => {
|
||||||
|
if (useViewer.getState().cameraDragging) return
|
||||||
|
emit('context-menu', e)
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user