feat: filter empty projects from public gallery (#125)
* feat(community): filter empty projects from public gallery Add is_empty column to projects table so the public gallery only shows projects with actual content. Scene graph emptiness is computed on save and on creation, and a backfill migration marks existing non-empty projects. Also cleans up import ordering, fixes README migration path, and removes stale feedback table migration. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Use ESNext modules and bundler resolution Update packages/db tsconfig to emit ESNext modules and use 'bundler' moduleResolution. This enables modern ESM output and bundler-style resolution for the db package (keeps outDir and rootDir unchanged). * feat(editor): add dark mode theme and grid visibility toggle Add theme state to viewer store with light/dark modes, dark-aware lighting and background colors, grid show/hide toggle in settings, and theme toggle buttons in the sidebar. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Add ground occluder, animated lighting & UI tweaks Introduce a ground occluder and smoother animated lighting while adjusting editor UI and icons. Viewer: add GroundOccluder (uses polygon-clipping to union slab/zone polygons) and AnimatedBackground, integrate both into the Canvas; improve Lights to interpolate intensities/colors/ambient for smooth theme transitions. Editor: refactor sidebar header to support inline project title editing and move the theme toggle; replace some lucide icons with image assets and add a settings icon (apps/editor/public/icons/settings.png). Also add polygon-clipping to the viewer package dependencies. * Update dark mode background color and clean up imports Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
5077011c26
commit
98c1d5247e
@@ -1,11 +1,11 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import Editor from '@/components/editor'
|
|
||||||
import { useParams, useRouter } from 'next/navigation'
|
import { useParams, useRouter } from 'next/navigation'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useProjectStore } from '@/features/community/lib/projects/store'
|
import Editor from '@/components/editor'
|
||||||
import { useAuth } from '@/features/community/lib/auth/hooks'
|
|
||||||
import { SceneLoader } from '@/components/ui/scene-loader'
|
import { SceneLoader } from '@/components/ui/scene-loader'
|
||||||
|
import { useAuth } from '@/features/community/lib/auth/hooks'
|
||||||
|
import { useProjectStore } from '@/features/community/lib/projects/store'
|
||||||
|
|
||||||
export default function EditorPage() {
|
export default function EditorPage() {
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
|
|||||||
@@ -32,6 +32,12 @@ export const Grid = ({
|
|||||||
fadeStrength?: number
|
fadeStrength?: number
|
||||||
revealRadius?: number
|
revealRadius?: number
|
||||||
}) => {
|
}) => {
|
||||||
|
const theme = useViewer((state) => state.theme)
|
||||||
|
|
||||||
|
// Use slightly lighter colors for dark mode grid to make it apparent
|
||||||
|
const effectiveCellColor = theme === 'dark' ? '#555566' : cellColor
|
||||||
|
const effectiveSectionColor = theme === 'dark' ? '#666677' : sectionColor
|
||||||
|
|
||||||
const cursorPositionRef = useRef(new Vector2(0, 0))
|
const cursorPositionRef = useRef(new Vector2(0, 0))
|
||||||
|
|
||||||
const material = useMemo(() => {
|
const material = useMemo(() => {
|
||||||
@@ -78,13 +84,16 @@ export const Grid = ({
|
|||||||
|
|
||||||
// Mix colors based on section grid
|
// Mix colors based on section grid
|
||||||
const gridColor = mix(
|
const gridColor = mix(
|
||||||
color(cellColor),
|
color(effectiveCellColor),
|
||||||
color(sectionColor),
|
color(effectiveSectionColor),
|
||||||
float(sectionThickness).mul(g2).min(1),
|
float(sectionThickness).mul(g2).min(1),
|
||||||
)
|
)
|
||||||
|
|
||||||
// Combined alpha with cursor fade
|
// Baseline alpha: small amount of opacity everywhere the grid exists
|
||||||
const alpha = g1.add(g2).mul(fade).mul(cursorFade)
|
const baseAlpha = float(0.4) // Subtle global visibility
|
||||||
|
|
||||||
|
// Combined alpha with cursor fade and baseline minimum
|
||||||
|
const alpha = g1.add(g2).mul(fade).mul(cursorFade.max(baseAlpha))
|
||||||
const finalAlpha = mix(alpha.mul(0.75), alpha, g2)
|
const finalAlpha = mix(alpha.mul(0.75), alpha, g2)
|
||||||
|
|
||||||
return new MeshBasicNodeMaterial({
|
return new MeshBasicNodeMaterial({
|
||||||
@@ -96,13 +105,14 @@ export const Grid = ({
|
|||||||
}, [
|
}, [
|
||||||
cellSize,
|
cellSize,
|
||||||
cellThickness,
|
cellThickness,
|
||||||
cellColor,
|
effectiveCellColor,
|
||||||
sectionSize,
|
sectionSize,
|
||||||
sectionThickness,
|
sectionThickness,
|
||||||
sectionColor,
|
effectiveSectionColor,
|
||||||
fadeDistance,
|
fadeDistance,
|
||||||
fadeStrength,
|
fadeStrength,
|
||||||
revealRadius,
|
revealRadius,
|
||||||
|
theme,
|
||||||
])
|
])
|
||||||
|
|
||||||
const gridRef = useRef<Mesh>(null!)
|
const gridRef = useRef<Mesh>(null!)
|
||||||
@@ -137,8 +147,10 @@ export const Grid = ({
|
|||||||
setGridY(newY)
|
setGridY(newY)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const showGrid = useViewer((state) => state.showGrid)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<mesh rotation-x={-Math.PI / 2} material={material} ref={gridRef}>
|
<mesh rotation-x={-Math.PI / 2} material={material} ref={gridRef} visible={showGrid}>
|
||||||
<planeGeometry args={[fadeDistance * 2, fadeDistance * 2]} />
|
<planeGeometry args={[fadeDistance * 2, fadeDistance * 2]} />
|
||||||
</mesh>
|
</mesh>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
import { useState, useRef, useEffect, useCallback } from "react";
|
import { useState, useRef, useEffect, useCallback } from "react";
|
||||||
import { IconRail, type PanelId } from "./icon-rail";
|
import { IconRail, type PanelId } from "./icon-rail";
|
||||||
import { Pencil } from "lucide-react";
|
import { Pencil, Moon, Sun, Monitor } from "lucide-react";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Sidebar,
|
Sidebar,
|
||||||
@@ -14,15 +15,23 @@ import { SettingsPanel } from "./panels/settings-panel";
|
|||||||
import { SitePanel } from "./panels/site-panel";
|
import { SitePanel } from "./panels/site-panel";
|
||||||
import { useProjectStore } from "@/features/community/lib/projects/store";
|
import { useProjectStore } from "@/features/community/lib/projects/store";
|
||||||
import { updateProjectName } from "@/features/community/lib/projects/actions";
|
import { updateProjectName } from "@/features/community/lib/projects/actions";
|
||||||
|
import { useViewer } from "@pascal-app/viewer";
|
||||||
|
|
||||||
export function AppSidebar() {
|
export function AppSidebar() {
|
||||||
const [activePanel, setActivePanel] = useState<PanelId>("site");
|
const [activePanel, setActivePanel] = useState<PanelId>("site");
|
||||||
const activeProject = useProjectStore((s) => s.activeProject);
|
const activeProject = useProjectStore((s) => s.activeProject);
|
||||||
|
const theme = useViewer((state) => state.theme);
|
||||||
|
const setTheme = useViewer((state) => state.setTheme);
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
|
||||||
const [isEditingTitle, setIsEditingTitle] = useState(false);
|
const [isEditingTitle, setIsEditingTitle] = useState(false);
|
||||||
const [titleValue, setTitleValue] = useState("");
|
const [titleValue, setTitleValue] = useState("");
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isEditingTitle) {
|
if (isEditingTitle) {
|
||||||
setTitleValue(activeProject?.name || "Untitled Project");
|
setTitleValue(activeProject?.name || "Untitled Project");
|
||||||
@@ -93,29 +102,84 @@ export function AppSidebar() {
|
|||||||
|
|
||||||
{/* Panel Content */}
|
{/* Panel Content */}
|
||||||
<div className="flex flex-1 flex-col overflow-hidden">
|
<div className="flex flex-1 flex-col overflow-hidden">
|
||||||
<SidebarHeader className="flex-col items-start justify-center px-3 py-3 gap-1 border-b border-border/50">
|
<SidebarHeader className="flex-col items-start justify-center px-3 py-3 gap-1 border-b border-border/50 relative">
|
||||||
{isEditingTitle ? (
|
<div className="flex w-full items-center justify-between gap-2">
|
||||||
<input
|
<div className="flex-1 min-w-0">
|
||||||
ref={inputRef}
|
{isEditingTitle ? (
|
||||||
type="text"
|
<input
|
||||||
value={titleValue}
|
ref={inputRef}
|
||||||
onChange={(e) => setTitleValue(e.target.value)}
|
type="text"
|
||||||
onKeyDown={handleKeyDown}
|
value={titleValue}
|
||||||
onBlur={handleSaveTitle}
|
onChange={(e) => setTitleValue(e.target.value)}
|
||||||
placeholder="Untitled Project"
|
onKeyDown={handleKeyDown}
|
||||||
className="w-full bg-transparent text-foreground outline-none border-b border-primary/50 focus:border-primary rounded-none px-0 py-0 m-0 h-7 font-semibold text-lg"
|
onBlur={handleSaveTitle}
|
||||||
/>
|
placeholder="Untitled Project"
|
||||||
) : (
|
className="w-full bg-transparent text-foreground outline-none border-b border-primary/50 focus:border-primary rounded-none px-0 py-0 m-0 h-7 font-semibold text-lg"
|
||||||
<div
|
/>
|
||||||
className="flex items-center gap-2 group/title cursor-pointer w-full h-7 border-b border-transparent"
|
) : (
|
||||||
onClick={() => setIsEditingTitle(true)}
|
<div
|
||||||
>
|
className="flex items-center gap-2 group/title cursor-pointer w-full h-7 border-b border-transparent"
|
||||||
<h1 className="font-semibold text-lg truncate flex-1">
|
onClick={() => setIsEditingTitle(true)}
|
||||||
{activeProject?.name || "Untitled Project"}
|
>
|
||||||
</h1>
|
<h1 className="font-semibold text-lg truncate">
|
||||||
<Pencil className="w-3.5 h-3.5 opacity-0 group-hover/title:opacity-100 transition-opacity text-muted-foreground shrink-0" />
|
{activeProject?.name || "Untitled Project"}
|
||||||
|
</h1>
|
||||||
|
<Pencil className="w-3.5 h-3.5 opacity-0 group-hover/title:opacity-100 transition-opacity text-muted-foreground shrink-0" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
{mounted && (
|
||||||
|
<button
|
||||||
|
className="shrink-0 flex items-center bg-accent/50 rounded-full p-1 border border-border/50 cursor-pointer"
|
||||||
|
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
|
||||||
|
type="button"
|
||||||
|
aria-label="Toggle theme"
|
||||||
|
>
|
||||||
|
<div className="relative flex">
|
||||||
|
{/* Sliding Background */}
|
||||||
|
<motion.div
|
||||||
|
className="absolute inset-0 bg-white shadow-sm rounded-full dark:bg-white/20"
|
||||||
|
initial={false}
|
||||||
|
animate={{
|
||||||
|
x: theme === "light" ? "100%" : "0%",
|
||||||
|
}}
|
||||||
|
transition={{
|
||||||
|
type: "spring",
|
||||||
|
stiffness: 500,
|
||||||
|
damping: 35,
|
||||||
|
}}
|
||||||
|
style={{ width: "50%" }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Dark Mode Icon */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"relative z-10 flex h-6 w-8 items-center justify-center rounded-full transition-colors duration-200 pointer-events-none",
|
||||||
|
theme === "dark"
|
||||||
|
? "text-foreground"
|
||||||
|
: "text-muted-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Moon className="h-3.5 w-3.5" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Light Mode Icon */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"relative z-10 flex h-6 w-8 items-center justify-center rounded-full transition-colors duration-200 pointer-events-none",
|
||||||
|
theme === "light"
|
||||||
|
? "text-foreground"
|
||||||
|
: "text-muted-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Sun className="h-3.5 w-3.5" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">
|
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">
|
||||||
{getPanelTitle()}
|
{getPanelTitle()}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Building2, Settings } from "lucide-react";
|
import { Moon, Sun } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/primitives/tooltip";
|
} from "@/components/ui/primitives/tooltip";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useViewer } from "@pascal-app/viewer";
|
||||||
|
|
||||||
export type PanelId = "site" | "settings";
|
export type PanelId = "site" | "settings";
|
||||||
|
|
||||||
@@ -18,9 +20,9 @@ interface IconRailProps {
|
|||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const panels: { id: PanelId; icon: typeof Building2; label: string }[] = [
|
const panels: { id: PanelId; iconSrc: string; label: string }[] = [
|
||||||
{ id: "site", icon: Building2, label: "Site" },
|
{ id: "site", iconSrc: "/icons/level.png", label: "Site" },
|
||||||
{ id: "settings", icon: Settings, label: "Settings" },
|
{ id: "settings", iconSrc: "/icons/settings.png", label: "Settings" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function IconRail({
|
export function IconRail({
|
||||||
@@ -28,10 +30,18 @@ export function IconRail({
|
|||||||
onPanelChange,
|
onPanelChange,
|
||||||
className,
|
className,
|
||||||
}: IconRailProps) {
|
}: IconRailProps) {
|
||||||
|
const theme = useViewer((state) => state.theme);
|
||||||
|
const setTheme = useViewer((state) => state.setTheme);
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex w-11 flex-col items-center gap-1 border-border/50 border-r py-2",
|
"flex h-full w-11 flex-col items-center gap-1 border-border/50 border-r py-2",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -58,7 +68,6 @@ export function IconRail({
|
|||||||
<div className="w-8 h-px bg-border/50 mb-1" />
|
<div className="w-8 h-px bg-border/50 mb-1" />
|
||||||
|
|
||||||
{panels.map((panel) => {
|
{panels.map((panel) => {
|
||||||
const Icon = panel.icon;
|
|
||||||
const isActive = activePanel === panel.id;
|
const isActive = activePanel === panel.id;
|
||||||
return (
|
return (
|
||||||
<Tooltip key={panel.id}>
|
<Tooltip key={panel.id}>
|
||||||
@@ -67,19 +76,45 @@ export function IconRail({
|
|||||||
className={cn(
|
className={cn(
|
||||||
"flex h-9 w-9 items-center justify-center rounded-lg transition-all",
|
"flex h-9 w-9 items-center justify-center rounded-lg transition-all",
|
||||||
isActive
|
isActive
|
||||||
? "bg-primary text-primary-foreground"
|
? "bg-accent"
|
||||||
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground",
|
: "hover:bg-accent",
|
||||||
)}
|
)}
|
||||||
onClick={() => onPanelChange(panel.id)}
|
onClick={() => onPanelChange(panel.id)}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<Icon className="h-4 w-4" />
|
<img
|
||||||
|
src={panel.iconSrc}
|
||||||
|
alt={panel.label}
|
||||||
|
className={cn(
|
||||||
|
"h-6 w-6 transition-all object-contain",
|
||||||
|
!isActive && "opacity-50 saturate-0"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="right">{panel.label}</TooltipContent>
|
<TooltipContent side="right">{panel.label}</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
{/* Spacer */}
|
||||||
|
<div className="flex-1" />
|
||||||
|
|
||||||
|
{/* Theme Toggle */}
|
||||||
|
{mounted && (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
className="flex h-9 w-9 items-center justify-center rounded-lg transition-all text-muted-foreground hover:bg-accent hover:text-accent-foreground mb-2"
|
||||||
|
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{theme === 'dark' ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="right">Toggle theme</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -309,6 +309,18 @@ export function SettingsPanel() {
|
|||||||
onCheckedChange={(checked) => handleVisibilityChange('showGuidesPublic', checked)}
|
onCheckedChange={(checked) => handleVisibilityChange('showGuidesPublic', checked)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium">Show Grid</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
Visible only in the editor
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={useViewer((state) => state.showGrid)}
|
||||||
|
onCheckedChange={(checked) => useViewer.getState().setShowGrid(checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -162,7 +162,7 @@ This feature requires:
|
|||||||
- `properties` - Property records
|
- `properties` - Property records
|
||||||
- `properties_addresses` - Property addresses
|
- `properties_addresses` - Property addresses
|
||||||
- `properties_models` - Scene graph models
|
- `properties_models` - Scene graph models
|
||||||
- Database migrations are managed in `packages/db/supabase/migrations/`
|
- Database migrations are managed in `supabase/migrations/`
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { createServerSupabaseClient } from '../database/server'
|
|||||||
import { getSession } from '../auth/server'
|
import { getSession } from '../auth/server'
|
||||||
import { createId } from '../utils/id-generator'
|
import { createId } from '../utils/id-generator'
|
||||||
import type { ActionResult } from '../projects/actions'
|
import type { ActionResult } from '../projects/actions'
|
||||||
|
import { isSceneGraphEmpty } from './scene-graph-utils'
|
||||||
|
|
||||||
export interface SceneGraph {
|
export interface SceneGraph {
|
||||||
nodes: Record<AnyNodeId, AnyNode>
|
nodes: Record<AnyNodeId, AnyNode>
|
||||||
@@ -166,6 +167,14 @@ export async function saveProjectModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Determine if scene graph is empty
|
||||||
|
const isEmpty = isSceneGraphEmpty(sceneGraph)
|
||||||
|
|
||||||
|
// Update the project's is_empty flag
|
||||||
|
await (supabase.from('projects') as any)
|
||||||
|
.update({ is_empty: isEmpty })
|
||||||
|
.eq('id', projectId)
|
||||||
|
|
||||||
// Check if a model already exists
|
// Check if a model already exists
|
||||||
const { data: existingModel } = await supabase
|
const { data: existingModel } = await supabase
|
||||||
.from('projects_models')
|
.from('projects_models')
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import type { SceneGraph } from './actions'
|
||||||
|
|
||||||
|
const DEFAULT_NODE_TYPES = ['site', 'building', 'level']
|
||||||
|
|
||||||
|
export function isSceneGraphEmpty(sceneGraph: SceneGraph | any): boolean {
|
||||||
|
if (!sceneGraph?.nodes) return true
|
||||||
|
|
||||||
|
const nodes = Object.values(sceneGraph.nodes) as any[]
|
||||||
|
|
||||||
|
if (nodes.length > 3) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasNonDefaultNodes = nodes.some((n) => !DEFAULT_NODE_TYPES.includes(n.type))
|
||||||
|
if (hasNonDefaultNodes) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const levelNode = nodes.find((n) => n.type === 'level')
|
||||||
|
if (Array.isArray(levelNode?.children) && levelNode.children.length > 0) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
@@ -8,7 +8,8 @@
|
|||||||
import { createServerSupabaseClient } from '../database/server'
|
import { createServerSupabaseClient } from '../database/server'
|
||||||
import { getSession } from '../auth/server'
|
import { getSession } from '../auth/server'
|
||||||
import { createId } from '../utils/id-generator'
|
import { createId } from '../utils/id-generator'
|
||||||
import type { CreateProjectParams, Project, Database } from './types'
|
import { isSceneGraphEmpty } from '../models/scene-graph-utils'
|
||||||
|
import type { CreateProjectParams, Project } from './types'
|
||||||
|
|
||||||
export type ActionResult<T = unknown> = {
|
export type ActionResult<T = unknown> = {
|
||||||
success: boolean
|
success: boolean
|
||||||
@@ -250,6 +251,9 @@ export async function createProject(params: CreateProjectParams): Promise<Action
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Determine if scene graph is empty
|
||||||
|
const isEmpty = params.sceneGraph ? isSceneGraphEmpty(params.sceneGraph) : true
|
||||||
|
|
||||||
// Create the project
|
// Create the project
|
||||||
const projectData = {
|
const projectData = {
|
||||||
id: projectId,
|
id: projectId,
|
||||||
@@ -257,6 +261,7 @@ export async function createProject(params: CreateProjectParams): Promise<Action
|
|||||||
address_id: addressId,
|
address_id: addressId,
|
||||||
owner_id: session.user.id,
|
owner_id: session.user.id,
|
||||||
is_private: params.isPrivate !== undefined ? params.isPrivate : true,
|
is_private: params.isPrivate !== undefined ? params.isPrivate : true,
|
||||||
|
is_empty: isEmpty,
|
||||||
details_json: params.center
|
details_json: params.center
|
||||||
? {
|
? {
|
||||||
coordinates: params.center,
|
coordinates: params.center,
|
||||||
@@ -401,6 +406,7 @@ export async function getPublicProjects(): Promise<ActionResult<Project[]>> {
|
|||||||
owner:auth_users!owner_id(id, name, username, image)
|
owner:auth_users!owner_id(id, name, username, image)
|
||||||
`)
|
`)
|
||||||
.eq('is_private', false)
|
.eq('is_private', false)
|
||||||
|
.eq('is_empty', false)
|
||||||
.order('views', { ascending: false })
|
.order('views', { ascending: false })
|
||||||
.limit(50)
|
.limit(50)
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export type DbProject = {
|
|||||||
created_at: string
|
created_at: string
|
||||||
updated_at: string
|
updated_at: string
|
||||||
is_private: boolean
|
is_private: boolean
|
||||||
|
is_empty: boolean
|
||||||
show_scans_public: boolean
|
show_scans_public: boolean
|
||||||
show_guides_public: boolean
|
show_guides_public: boolean
|
||||||
views: number
|
views: number
|
||||||
@@ -106,6 +107,7 @@ export type Project = {
|
|||||||
updated_at: string
|
updated_at: string
|
||||||
// Community features
|
// Community features
|
||||||
is_private: boolean
|
is_private: boolean
|
||||||
|
is_empty: boolean
|
||||||
show_scans_public: boolean
|
show_scans_public: boolean
|
||||||
show_guides_public: boolean
|
show_guides_public: boolean
|
||||||
views: number
|
views: number
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 122 KiB |
@@ -174,6 +174,7 @@
|
|||||||
"name": "@pascal-app/viewer",
|
"name": "@pascal-app/viewer",
|
||||||
"version": "0.1.13",
|
"version": "0.1.13",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"polygon-clipping": "^0.15.7",
|
||||||
"zustand": "^5",
|
"zustand": "^5",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -1314,6 +1315,8 @@
|
|||||||
|
|
||||||
"picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
"picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
||||||
|
|
||||||
|
"polygon-clipping": ["polygon-clipping@0.15.7", "", { "dependencies": { "robust-predicates": "^3.0.2", "splaytree": "^3.1.0" } }, "sha512-nhfdr83ECBg6xtqOAJab1tbksbBAOMUltN60bU+llHVOL0e5Onm1WpAXXWXVB39L8AJFssoIhEVuy/S90MmotA=="],
|
||||||
|
|
||||||
"portless": ["portless@0.4.2", "", { "dependencies": { "chalk": "^5.3.0" }, "os": [ "linux", "darwin", ], "bin": { "portless": "dist/cli.js" } }, "sha512-/G3jIeD1XokoO9KY/lUGTV9irKz3tgx8yqHkz+hvj/86QeR219GvYFB+QEfpvjRlvvLVJa5vE7BkRfBZBe/lQg=="],
|
"portless": ["portless@0.4.2", "", { "dependencies": { "chalk": "^5.3.0" }, "os": [ "linux", "darwin", ], "bin": { "portless": "dist/cli.js" } }, "sha512-/G3jIeD1XokoO9KY/lUGTV9irKz3tgx8yqHkz+hvj/86QeR219GvYFB+QEfpvjRlvvLVJa5vE7BkRfBZBe/lQg=="],
|
||||||
|
|
||||||
"possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="],
|
"possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="],
|
||||||
@@ -1388,6 +1391,8 @@
|
|||||||
|
|
||||||
"reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="],
|
"reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="],
|
||||||
|
|
||||||
|
"robust-predicates": ["robust-predicates@3.0.2", "", {}, "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg=="],
|
||||||
|
|
||||||
"rou3": ["rou3@0.7.12", "", {}, "sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg=="],
|
"rou3": ["rou3@0.7.12", "", {}, "sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg=="],
|
||||||
|
|
||||||
"run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="],
|
"run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="],
|
||||||
@@ -1438,6 +1443,8 @@
|
|||||||
|
|
||||||
"source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="],
|
"source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="],
|
||||||
|
|
||||||
|
"splaytree": ["splaytree@3.2.3", "", {}, "sha512-7OXrNWzy6CK+r7Ch9OLPBDTKfB6XlWHjX4P0RU5B3IgFuWPeYN0XtRtlexGRjgbQxpfaUve6jTAwBGWuGntz/w=="],
|
||||||
|
|
||||||
"split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="],
|
"split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="],
|
||||||
|
|
||||||
"stats-gl": ["stats-gl@2.4.2", "", { "dependencies": { "@types/three": "*", "three": "^0.170.0" } }, "sha512-g5O9B0hm9CvnM36+v7SFl39T7hmAlv541tU81ME8YeSb3i1CIP5/QdDeSB3A0la0bKNHpxpwxOVRo2wFTYEosQ=="],
|
"stats-gl": ["stats-gl@2.4.2", "", { "dependencies": { "@types/three": "*", "three": "^0.170.0" } }, "sha512-g5O9B0hm9CvnM36+v7SFl39T7hmAlv541tU81ME8YeSb3i1CIP5/QdDeSB3A0la0bKNHpxpwxOVRo2wFTYEosQ=="],
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export const projects = pgTable(
|
|||||||
metadata: t.jsonb('metadata'),
|
metadata: t.jsonb('metadata'),
|
||||||
// Community features
|
// Community features
|
||||||
isPrivate: t.boolean('is_private').notNull().default(true),
|
isPrivate: t.boolean('is_private').notNull().default(true),
|
||||||
|
isEmpty: t.boolean('is_empty').notNull().default(true),
|
||||||
showScansPublic: t.boolean('show_scans_public').notNull().default(true),
|
showScansPublic: t.boolean('show_scans_public').notNull().default(true),
|
||||||
showGuidesPublic: t.boolean('show_guides_public').notNull().default(true),
|
showGuidesPublic: t.boolean('show_guides_public').notNull().default(true),
|
||||||
views: t.integer('views').notNull().default(0),
|
views: t.integer('views').notNull().default(0),
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
-- 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);
|
|
||||||
@@ -2,7 +2,9 @@
|
|||||||
"extends": "@repo/typescript-config/base.json",
|
"extends": "@repo/typescript-config/base.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"outDir": "dist",
|
"outDir": "dist",
|
||||||
"rootDir": "src"
|
"rootDir": "src",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler"
|
||||||
},
|
},
|
||||||
"include": ["src"],
|
"include": ["src"],
|
||||||
"exclude": ["node_modules", "dist"]
|
"exclude": ["node_modules", "dist"]
|
||||||
|
|||||||
@@ -29,6 +29,7 @@
|
|||||||
"three": "^0.183"
|
"three": "^0.183"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"polygon-clipping": "^0.15.7",
|
||||||
"zustand": "^5"
|
"zustand": "^5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { useScene } from '@pascal-app/core'
|
||||||
|
import { useMemo } from 'react'
|
||||||
|
import * as THREE from 'three'
|
||||||
|
import useViewer from '../../store/use-viewer'
|
||||||
|
import polygonClipping from 'polygon-clipping'
|
||||||
|
|
||||||
|
export const GroundOccluder = () => {
|
||||||
|
const theme = useViewer((state) => state.theme)
|
||||||
|
const bgColor = theme === 'dark' ? '#1f2433' : '#fafafa'
|
||||||
|
|
||||||
|
const nodes = useScene((state) => state.nodes)
|
||||||
|
|
||||||
|
const shape = useMemo(() => {
|
||||||
|
const s = new THREE.Shape()
|
||||||
|
const size = 100000
|
||||||
|
// Create outer infinite plane
|
||||||
|
s.moveTo(-size, -size)
|
||||||
|
s.lineTo(size, -size)
|
||||||
|
s.lineTo(size, size)
|
||||||
|
s.lineTo(-size, size)
|
||||||
|
s.closePath()
|
||||||
|
|
||||||
|
// Collect all polygons for slabs and zones
|
||||||
|
const polygons: [number, number][][] = []
|
||||||
|
|
||||||
|
Object.values(nodes).forEach((node) => {
|
||||||
|
if ((node.type === 'slab' || node.type === 'zone') && node.polygon && node.polygon.length >= 3) {
|
||||||
|
polygons.push(node.polygon as [number, number][])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (polygons.length > 0) {
|
||||||
|
// Format for polygon-clipping: [[[x, y], [x, y], ...]]
|
||||||
|
const multiPolygons = polygons.map(pts => {
|
||||||
|
const ring = pts.map(p => [p[0], -p[1]] as [number, number]) // Negate Y (which was Z)
|
||||||
|
return [ring]
|
||||||
|
})
|
||||||
|
|
||||||
|
// Union all polygons together to prevent artifacts from overlapping
|
||||||
|
const unionedPolygons = polygonClipping.union(multiPolygons[0]!, ...multiPolygons.slice(1))
|
||||||
|
|
||||||
|
// Add each resulting unioned polygon as a hole
|
||||||
|
for (const geom of unionedPolygons) {
|
||||||
|
// First ring in each geometry is the exterior ring
|
||||||
|
if (geom.length > 0) {
|
||||||
|
const ring = geom[0]!
|
||||||
|
const hole = new THREE.Path()
|
||||||
|
|
||||||
|
if (ring.length > 0) {
|
||||||
|
hole.moveTo(ring[0]![0], ring[0]![1])
|
||||||
|
for (let i = 1; i < ring.length; i++) {
|
||||||
|
hole.lineTo(ring[i]![0], ring[i]![1])
|
||||||
|
}
|
||||||
|
hole.closePath()
|
||||||
|
s.holes.push(hole)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return s
|
||||||
|
}, [nodes])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<mesh rotation-x={-Math.PI / 2} position-y={-0.05}>
|
||||||
|
<shapeGeometry args={[shape]} />
|
||||||
|
<meshBasicMaterial color={bgColor} depthWrite={true} />
|
||||||
|
</mesh>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,22 +1,57 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { CeilingSystem, DoorSystem, ItemSystem, RoofSystem, SlabSystem, WallSystem, WindowSystem } from '@pascal-app/core'
|
import {
|
||||||
|
CeilingSystem,
|
||||||
|
DoorSystem,
|
||||||
|
ItemSystem,
|
||||||
|
RoofSystem,
|
||||||
|
SlabSystem,
|
||||||
|
WallSystem,
|
||||||
|
WindowSystem,
|
||||||
|
} from '@pascal-app/core'
|
||||||
import { Bvh } from '@react-three/drei'
|
import { Bvh } from '@react-three/drei'
|
||||||
import { Canvas, extend, type ThreeToJSXElements } from '@react-three/fiber'
|
import { Canvas, extend, type ThreeToJSXElements, useFrame } from '@react-three/fiber'
|
||||||
|
import { useEffect, useMemo, useRef } from 'react'
|
||||||
import * as THREE from 'three/webgpu'
|
import * as THREE from 'three/webgpu'
|
||||||
|
import useViewer from '../../store/use-viewer'
|
||||||
import { GuideSystem } from '../../systems/guide/guide-system'
|
import { GuideSystem } from '../../systems/guide/guide-system'
|
||||||
import { LevelSystem } from '../../systems/level/level-system'
|
import { LevelSystem } from '../../systems/level/level-system'
|
||||||
import { ScanSystem } from '../../systems/scan/scan-system'
|
import { ScanSystem } from '../../systems/scan/scan-system'
|
||||||
import { WallCutout } from '../../systems/wall/wall-cutout'
|
import { WallCutout } from '../../systems/wall/wall-cutout'
|
||||||
import { ZoneSystem } from '../../systems/zone/zone-system'
|
import { ZoneSystem } from '../../systems/zone/zone-system'
|
||||||
import { SceneRenderer } from '../renderers/scene-renderer'
|
import { SceneRenderer } from '../renderers/scene-renderer'
|
||||||
|
import { GroundOccluder } from './ground-occluder'
|
||||||
import { Lights } from './lights'
|
import { Lights } from './lights'
|
||||||
import PostProcessing from './post-processing'
|
import PostProcessing from './post-processing'
|
||||||
import { SelectionManager } from './selection-manager'
|
import { SelectionManager } from './selection-manager'
|
||||||
import { ViewerCamera } from './viewer-camera'
|
import { ViewerCamera } from './viewer-camera'
|
||||||
|
|
||||||
import { useEffect } from 'react'
|
function AnimatedBackground({ isDark }: { isDark: boolean }) {
|
||||||
import useViewer from '../../store/use-viewer'
|
const targetColor = useMemo(() => new THREE.Color(), [])
|
||||||
|
const initialized = useRef(false)
|
||||||
|
|
||||||
|
useFrame(({ scene }, delta) => {
|
||||||
|
const dt = Math.min(delta, 0.1) * 4
|
||||||
|
const targetHex = isDark ? '#1f2433' : '#ffffff'
|
||||||
|
|
||||||
|
if (!scene.background || !(scene.background instanceof THREE.Color)) {
|
||||||
|
scene.background = new THREE.Color(targetHex)
|
||||||
|
initialized.current = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!initialized.current) {
|
||||||
|
scene.background.set(targetHex)
|
||||||
|
initialized.current = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
targetColor.set(targetHex)
|
||||||
|
scene.background.lerp(targetColor, dt)
|
||||||
|
})
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
declare module '@react-three/fiber' {
|
declare module '@react-three/fiber' {
|
||||||
interface ThreeElements extends ThreeToJSXElements<typeof THREE> {}
|
interface ThreeElements extends ThreeToJSXElements<typeof THREE> {}
|
||||||
@@ -30,8 +65,15 @@ interface ViewerProps {
|
|||||||
isEditor?: boolean
|
isEditor?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const Viewer: React.FC<ViewerProps> = ({ children, selectionManager = 'default', isEditor = false }) => {
|
const Viewer: React.FC<ViewerProps> = ({
|
||||||
|
children,
|
||||||
|
selectionManager = 'default',
|
||||||
|
isEditor = false,
|
||||||
|
}) => {
|
||||||
const setIsEditor = useViewer((state) => state.setIsEditor)
|
const setIsEditor = useViewer((state) => state.setIsEditor)
|
||||||
|
const theme = useViewer((state) => state.theme)
|
||||||
|
|
||||||
|
const bgColor = theme === 'dark' ? '#1f2433' : '#fafafa'
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsEditor(isEditor)
|
setIsEditor(isEditor)
|
||||||
@@ -40,7 +82,7 @@ const Viewer: React.FC<ViewerProps> = ({ children, selectionManager = 'default',
|
|||||||
return (
|
return (
|
||||||
<Canvas
|
<Canvas
|
||||||
dpr={[1, 1.5]}
|
dpr={[1, 1.5]}
|
||||||
className={'bg-[#fafafa]'}
|
className={`transition-colors duration-700 ${theme === 'dark' ? 'bg-[#1f2433]' : 'bg-[#fafafa]'}`}
|
||||||
gl={(props) => {
|
gl={(props) => {
|
||||||
const renderer = new THREE.WebGPURenderer(props as any)
|
const renderer = new THREE.WebGPURenderer(props as any)
|
||||||
renderer.toneMapping = THREE.ACESFilmicToneMapping
|
renderer.toneMapping = THREE.ACESFilmicToneMapping
|
||||||
@@ -53,7 +95,8 @@ const Viewer: React.FC<ViewerProps> = ({ children, selectionManager = 'default',
|
|||||||
}}
|
}}
|
||||||
camera={{ position: [50, 50, 50], fov: 50 }}
|
camera={{ position: [50, 50, 50], fov: 50 }}
|
||||||
>
|
>
|
||||||
<color attach="background" args={['#fafafa']} />
|
<AnimatedBackground isDark={theme === 'dark'} />
|
||||||
|
<GroundOccluder />
|
||||||
<ViewerCamera />
|
<ViewerCamera />
|
||||||
|
|
||||||
{/* <directionalLight position={[10, 10, 5]} intensity={0.5} castShadow
|
{/* <directionalLight position={[10, 10, 5]} intensity={0.5} castShadow
|
||||||
|
|||||||
@@ -1,26 +1,100 @@
|
|||||||
import { useRef } from 'react'
|
import { useRef, useMemo } from 'react'
|
||||||
import type { DirectionalLight, OrthographicCamera } from 'three/webgpu'
|
import { useFrame } from '@react-three/fiber'
|
||||||
|
import * as THREE from 'three/webgpu'
|
||||||
|
import type { DirectionalLight, OrthographicCamera, AmbientLight } from 'three/webgpu'
|
||||||
|
import useViewer from '../../store/use-viewer'
|
||||||
|
|
||||||
export function Lights() {
|
export function Lights() {
|
||||||
const lightRef = useRef<DirectionalLight>(null)
|
const theme = useViewer((state) => state.theme)
|
||||||
|
const isDark = theme === 'dark'
|
||||||
|
|
||||||
|
const light1Ref = useRef<DirectionalLight>(null)
|
||||||
const shadowCamera = useRef<OrthographicCamera>(null)
|
const shadowCamera = useRef<OrthographicCamera>(null)
|
||||||
const shadowCameraSize = 50 // The "area" around the camera to shadow
|
const shadowCameraSize = 50 // The "area" around the camera to shadow
|
||||||
|
|
||||||
// useHelper(lightRef, DirectionalLightHelper, 1, 'red')
|
const light2Ref = useRef<DirectionalLight>(null)
|
||||||
// useHelper(shadowCamera, CameraHelper)
|
const light3Ref = useRef<DirectionalLight>(null)
|
||||||
|
const ambientRef = useRef<AmbientLight>(null)
|
||||||
|
|
||||||
|
const initialized = useRef(false)
|
||||||
|
|
||||||
|
const targets = useMemo(() => ({
|
||||||
|
l1Color: new THREE.Color(),
|
||||||
|
l2Color: new THREE.Color(),
|
||||||
|
l3Color: new THREE.Color(),
|
||||||
|
ambColor: new THREE.Color(),
|
||||||
|
}), [])
|
||||||
|
|
||||||
|
useFrame((_, delta) => {
|
||||||
|
// clamp delta to avoid huge jumps on tab switch
|
||||||
|
const dt = Math.min(delta, 0.1) * 4
|
||||||
|
|
||||||
|
if (!initialized.current) {
|
||||||
|
if (light1Ref.current) {
|
||||||
|
light1Ref.current.intensity = isDark ? 0.8 : 4
|
||||||
|
light1Ref.current.color.set(isDark ? '#e0e5ff' : '#ffffff')
|
||||||
|
// @ts-ignore
|
||||||
|
if (light1Ref.current.shadow) light1Ref.current.shadow.intensity = isDark ? 0.8 : 0.4
|
||||||
|
}
|
||||||
|
if (light2Ref.current) {
|
||||||
|
light2Ref.current.intensity = isDark ? 0.2 : 0.75
|
||||||
|
light2Ref.current.color.set(isDark ? '#8090ff' : '#ffffff')
|
||||||
|
}
|
||||||
|
if (light3Ref.current) {
|
||||||
|
light3Ref.current.intensity = isDark ? 0.3 : 1
|
||||||
|
light3Ref.current.color.set(isDark ? '#a0b0ff' : '#ffffff')
|
||||||
|
}
|
||||||
|
if (ambientRef.current) {
|
||||||
|
ambientRef.current.intensity = isDark ? 0.15 : 0.5
|
||||||
|
ambientRef.current.color.set(isDark ? '#a0b0ff' : '#ffffff')
|
||||||
|
}
|
||||||
|
initialized.current = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (light1Ref.current) {
|
||||||
|
light1Ref.current.intensity = THREE.MathUtils.lerp(light1Ref.current.intensity, isDark ? 0.8 : 4, dt)
|
||||||
|
targets.l1Color.set(isDark ? '#e0e5ff' : '#ffffff')
|
||||||
|
light1Ref.current.color.lerp(targets.l1Color, dt)
|
||||||
|
|
||||||
|
if (light1Ref.current.shadow) {
|
||||||
|
// @ts-ignore
|
||||||
|
if (light1Ref.current.shadow.intensity !== undefined) {
|
||||||
|
// @ts-ignore
|
||||||
|
light1Ref.current.shadow.intensity = THREE.MathUtils.lerp(light1Ref.current.shadow.intensity, isDark ? 0.8 : 0.4, dt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (light2Ref.current) {
|
||||||
|
light2Ref.current.intensity = THREE.MathUtils.lerp(light2Ref.current.intensity, isDark ? 0.2 : 0.75, dt)
|
||||||
|
targets.l2Color.set(isDark ? '#8090ff' : '#ffffff')
|
||||||
|
light2Ref.current.color.lerp(targets.l2Color, dt)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (light3Ref.current) {
|
||||||
|
light3Ref.current.intensity = THREE.MathUtils.lerp(light3Ref.current.intensity, isDark ? 0.3 : 1, dt)
|
||||||
|
targets.l3Color.set(isDark ? '#a0b0ff' : '#ffffff')
|
||||||
|
light3Ref.current.color.lerp(targets.l3Color, dt)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ambientRef.current) {
|
||||||
|
ambientRef.current.intensity = THREE.MathUtils.lerp(ambientRef.current.intensity, isDark ? 0.15 : 0.5, dt)
|
||||||
|
targets.ambColor.set(isDark ? '#a0b0ff' : '#ffffff')
|
||||||
|
ambientRef.current.color.lerp(targets.ambColor, dt)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<directionalLight
|
<directionalLight
|
||||||
ref={lightRef}
|
ref={light1Ref}
|
||||||
position={[10, 10, 10]}
|
position={[10, 10, 10]}
|
||||||
castShadow
|
castShadow
|
||||||
intensity={4}
|
|
||||||
shadow-bias={-0.002}
|
shadow-bias={-0.002}
|
||||||
shadow-normalBias={0.3}
|
shadow-normalBias={0.3}
|
||||||
shadow-mapSize={[1024, 1024]}
|
shadow-mapSize={[1024, 1024]}
|
||||||
shadow-radius={3}
|
shadow-radius={3}
|
||||||
shadow-intensity={0.4}
|
|
||||||
>
|
>
|
||||||
<orthographicCamera
|
<orthographicCamera
|
||||||
ref={shadowCamera}
|
ref={shadowCamera}
|
||||||
@@ -35,18 +109,16 @@ export function Lights() {
|
|||||||
</directionalLight>
|
</directionalLight>
|
||||||
|
|
||||||
<directionalLight
|
<directionalLight
|
||||||
|
ref={light2Ref}
|
||||||
position={[-10, 10, -10]}
|
position={[-10, 10, -10]}
|
||||||
intensity={0.75}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<directionalLight
|
<directionalLight
|
||||||
|
ref={light3Ref}
|
||||||
position={[-10, 10, 10]}
|
position={[-10, 10, 10]}
|
||||||
intensity={1}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ambientLight intensity={0.5}
|
<ambientLight ref={ambientRef} />
|
||||||
color='white' />
|
|
||||||
{/* <Environment preset="sunset" environmentIntensity={0.4} /> */}
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,9 @@ type ViewerState = {
|
|||||||
cameraMode: 'perspective' | 'orthographic'
|
cameraMode: 'perspective' | 'orthographic'
|
||||||
setCameraMode: (mode: 'perspective' | 'orthographic') => void
|
setCameraMode: (mode: 'perspective' | 'orthographic') => void
|
||||||
|
|
||||||
|
theme: 'light' | 'dark'
|
||||||
|
setTheme: (theme: 'light' | 'dark') => void
|
||||||
|
|
||||||
levelMode: 'stacked' | 'exploded' | 'solo' | 'manual'
|
levelMode: 'stacked' | 'exploded' | 'solo' | 'manual'
|
||||||
setLevelMode: (mode: 'stacked' | 'exploded' | 'solo' | 'manual') => void
|
setLevelMode: (mode: 'stacked' | 'exploded' | 'solo' | 'manual') => void
|
||||||
|
|
||||||
@@ -47,9 +50,12 @@ type ViewerState = {
|
|||||||
showGuides: boolean
|
showGuides: boolean
|
||||||
setShowGuides: (show: boolean) => void
|
setShowGuides: (show: boolean) => void
|
||||||
|
|
||||||
|
showGrid: boolean
|
||||||
|
setShowGrid: (show: boolean) => void
|
||||||
|
|
||||||
projectId: string | null
|
projectId: string | null
|
||||||
setProjectId: (id: string | null) => void
|
setProjectId: (id: string | null) => void
|
||||||
projectPreferences: Record<string, { showScans?: boolean, showGuides?: boolean }>
|
projectPreferences: Record<string, { showScans?: boolean, showGuides?: boolean, showGrid?: boolean }>
|
||||||
|
|
||||||
// Smart selection update
|
// Smart selection update
|
||||||
setSelection: (updates: Partial<SelectionPath>) => void
|
setSelection: (updates: Partial<SelectionPath>) => void
|
||||||
@@ -77,6 +83,9 @@ const useViewer = create<ViewerState>()(
|
|||||||
cameraMode: "perspective",
|
cameraMode: "perspective",
|
||||||
setCameraMode: (mode) => set({ cameraMode: mode }),
|
setCameraMode: (mode) => set({ cameraMode: mode }),
|
||||||
|
|
||||||
|
theme: "light",
|
||||||
|
setTheme: (theme) => set({ theme }),
|
||||||
|
|
||||||
levelMode: "stacked",
|
levelMode: "stacked",
|
||||||
setLevelMode: (mode) => set({ levelMode: mode }),
|
setLevelMode: (mode) => set({ levelMode: mode }),
|
||||||
|
|
||||||
@@ -109,6 +118,19 @@ const useViewer = create<ViewerState>()(
|
|||||||
return { showGuides: show, projectPreferences };
|
return { showGuides: show, projectPreferences };
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
showGrid: true,
|
||||||
|
setShowGrid: (show) =>
|
||||||
|
set((state) => {
|
||||||
|
const projectPreferences = { ...(state.projectPreferences || {}) };
|
||||||
|
if (state.projectId) {
|
||||||
|
projectPreferences[state.projectId] = {
|
||||||
|
...(projectPreferences[state.projectId] || {}),
|
||||||
|
showGrid: show,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { showGrid: show, projectPreferences };
|
||||||
|
}),
|
||||||
|
|
||||||
projectId: null,
|
projectId: null,
|
||||||
setProjectId: (id) =>
|
setProjectId: (id) =>
|
||||||
set((state) => {
|
set((state) => {
|
||||||
@@ -118,6 +140,7 @@ const useViewer = create<ViewerState>()(
|
|||||||
projectId: id,
|
projectId: id,
|
||||||
showScans: prefs.showScans ?? true,
|
showScans: prefs.showScans ?? true,
|
||||||
showGuides: prefs.showGuides ?? true,
|
showGuides: prefs.showGuides ?? true,
|
||||||
|
showGrid: prefs.showGrid ?? true,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
projectPreferences: {},
|
projectPreferences: {},
|
||||||
@@ -165,6 +188,7 @@ const useViewer = create<ViewerState>()(
|
|||||||
name: 'viewer-preferences',
|
name: 'viewer-preferences',
|
||||||
partialize: (state) => ({
|
partialize: (state) => ({
|
||||||
cameraMode: state.cameraMode,
|
cameraMode: state.cameraMode,
|
||||||
|
theme: state.theme,
|
||||||
levelMode: state.levelMode,
|
levelMode: state.levelMode,
|
||||||
wallMode: state.wallMode,
|
wallMode: state.wallMode,
|
||||||
projectPreferences: state.projectPreferences,
|
projectPreferences: state.projectPreferences,
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "projects" ADD COLUMN "is_empty" boolean DEFAULT true NOT NULL;
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
-- Backfill script to update the is_empty flag for existing projects
|
||||||
|
-- This can be run multiple times safely as logic evolves
|
||||||
|
|
||||||
|
UPDATE projects p
|
||||||
|
SET is_empty = false
|
||||||
|
FROM (
|
||||||
|
-- 1. Get the latest model for each project
|
||||||
|
SELECT DISTINCT ON (project_id) project_id, scene_graph
|
||||||
|
FROM projects_models
|
||||||
|
WHERE deleted_at IS NULL
|
||||||
|
ORDER BY project_id, version DESC, created_at DESC
|
||||||
|
) latest_model
|
||||||
|
WHERE p.id = latest_model.project_id
|
||||||
|
AND p.is_empty = true -- Only process projects that are currently marked empty
|
||||||
|
AND latest_model.scene_graph IS NOT NULL
|
||||||
|
AND latest_model.scene_graph->'nodes' IS NOT NULL
|
||||||
|
AND (
|
||||||
|
-- Condition 1: More than 3 nodes
|
||||||
|
(SELECT count(*) FROM jsonb_object_keys(latest_model.scene_graph->'nodes')) > 3
|
||||||
|
|
||||||
|
OR
|
||||||
|
|
||||||
|
-- Condition 2 & 3: Iterate through nodes to check type and children
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM jsonb_each(latest_model.scene_graph->'nodes') AS n(key, value)
|
||||||
|
WHERE
|
||||||
|
-- Not a default node type
|
||||||
|
(n.value->>'type' NOT IN ('site', 'building', 'level'))
|
||||||
|
OR
|
||||||
|
-- Or is a level node with > 0 children
|
||||||
|
(
|
||||||
|
n.value->>'type' = 'level'
|
||||||
|
AND jsonb_typeof(n.value->'children') = 'array'
|
||||||
|
AND jsonb_array_length(n.value->'children') > 0
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -50,6 +50,20 @@
|
|||||||
"when": 1772216042000,
|
"when": 1772216042000,
|
||||||
"tag": "20260227173402_create_missing_storage_buckets",
|
"tag": "20260227173402_create_missing_storage_buckets",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 7,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1772222557281,
|
||||||
|
"tag": "20260227200237_brown_quasimodo",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 8,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1772225008787,
|
||||||
|
"tag": "20260227204328_backfill_empty_projects",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user