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
@@ -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'
|
||||
|
||||
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 { 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 useViewer from '../../store/use-viewer'
|
||||
import { GuideSystem } from '../../systems/guide/guide-system'
|
||||
import { LevelSystem } from '../../systems/level/level-system'
|
||||
import { ScanSystem } from '../../systems/scan/scan-system'
|
||||
import { WallCutout } from '../../systems/wall/wall-cutout'
|
||||
import { ZoneSystem } from '../../systems/zone/zone-system'
|
||||
import { SceneRenderer } from '../renderers/scene-renderer'
|
||||
import { GroundOccluder } from './ground-occluder'
|
||||
import { Lights } from './lights'
|
||||
import PostProcessing from './post-processing'
|
||||
import { SelectionManager } from './selection-manager'
|
||||
import { ViewerCamera } from './viewer-camera'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import useViewer from '../../store/use-viewer'
|
||||
function AnimatedBackground({ isDark }: { isDark: boolean }) {
|
||||
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' {
|
||||
interface ThreeElements extends ThreeToJSXElements<typeof THREE> {}
|
||||
@@ -30,8 +65,15 @@ interface ViewerProps {
|
||||
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 theme = useViewer((state) => state.theme)
|
||||
|
||||
const bgColor = theme === 'dark' ? '#1f2433' : '#fafafa'
|
||||
|
||||
useEffect(() => {
|
||||
setIsEditor(isEditor)
|
||||
@@ -40,7 +82,7 @@ const Viewer: React.FC<ViewerProps> = ({ children, selectionManager = 'default',
|
||||
return (
|
||||
<Canvas
|
||||
dpr={[1, 1.5]}
|
||||
className={'bg-[#fafafa]'}
|
||||
className={`transition-colors duration-700 ${theme === 'dark' ? 'bg-[#1f2433]' : 'bg-[#fafafa]'}`}
|
||||
gl={(props) => {
|
||||
const renderer = new THREE.WebGPURenderer(props as any)
|
||||
renderer.toneMapping = THREE.ACESFilmicToneMapping
|
||||
@@ -53,7 +95,8 @@ const Viewer: React.FC<ViewerProps> = ({ children, selectionManager = 'default',
|
||||
}}
|
||||
camera={{ position: [50, 50, 50], fov: 50 }}
|
||||
>
|
||||
<color attach="background" args={['#fafafa']} />
|
||||
<AnimatedBackground isDark={theme === 'dark'} />
|
||||
<GroundOccluder />
|
||||
<ViewerCamera />
|
||||
|
||||
{/* <directionalLight position={[10, 10, 5]} intensity={0.5} castShadow
|
||||
|
||||
@@ -1,26 +1,100 @@
|
||||
import { useRef } from 'react'
|
||||
import type { DirectionalLight, OrthographicCamera } from 'three/webgpu'
|
||||
import { useRef, useMemo } from 'react'
|
||||
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() {
|
||||
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 shadowCameraSize = 50 // The "area" around the camera to shadow
|
||||
|
||||
// useHelper(lightRef, DirectionalLightHelper, 1, 'red')
|
||||
// useHelper(shadowCamera, CameraHelper)
|
||||
const light2Ref = useRef<DirectionalLight>(null)
|
||||
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 (
|
||||
<>
|
||||
<directionalLight
|
||||
ref={lightRef}
|
||||
ref={light1Ref}
|
||||
position={[10, 10, 10]}
|
||||
castShadow
|
||||
intensity={4}
|
||||
shadow-bias={-0.002}
|
||||
shadow-normalBias={0.3}
|
||||
shadow-mapSize={[1024, 1024]}
|
||||
shadow-radius={3}
|
||||
shadow-intensity={0.4}
|
||||
>
|
||||
<orthographicCamera
|
||||
ref={shadowCamera}
|
||||
@@ -35,18 +109,16 @@ export function Lights() {
|
||||
</directionalLight>
|
||||
|
||||
<directionalLight
|
||||
ref={light2Ref}
|
||||
position={[-10, 10, -10]}
|
||||
intensity={0.75}
|
||||
/>
|
||||
|
||||
<directionalLight
|
||||
ref={light3Ref}
|
||||
position={[-10, 10, 10]}
|
||||
intensity={1}
|
||||
/>
|
||||
|
||||
<ambientLight intensity={0.5}
|
||||
color='white' />
|
||||
{/* <Environment preset="sunset" environmentIntensity={0.4} /> */}
|
||||
<ambientLight ref={ambientRef} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -35,6 +35,9 @@ type ViewerState = {
|
||||
cameraMode: 'perspective' | 'orthographic'
|
||||
setCameraMode: (mode: 'perspective' | 'orthographic') => void
|
||||
|
||||
theme: 'light' | 'dark'
|
||||
setTheme: (theme: 'light' | 'dark') => void
|
||||
|
||||
levelMode: 'stacked' | 'exploded' | 'solo' | 'manual'
|
||||
setLevelMode: (mode: 'stacked' | 'exploded' | 'solo' | 'manual') => void
|
||||
|
||||
@@ -47,9 +50,12 @@ type ViewerState = {
|
||||
showGuides: boolean
|
||||
setShowGuides: (show: boolean) => void
|
||||
|
||||
showGrid: boolean
|
||||
setShowGrid: (show: boolean) => void
|
||||
|
||||
projectId: string | null
|
||||
setProjectId: (id: string | null) => void
|
||||
projectPreferences: Record<string, { showScans?: boolean, showGuides?: boolean }>
|
||||
projectPreferences: Record<string, { showScans?: boolean, showGuides?: boolean, showGrid?: boolean }>
|
||||
|
||||
// Smart selection update
|
||||
setSelection: (updates: Partial<SelectionPath>) => void
|
||||
@@ -77,6 +83,9 @@ const useViewer = create<ViewerState>()(
|
||||
cameraMode: "perspective",
|
||||
setCameraMode: (mode) => set({ cameraMode: mode }),
|
||||
|
||||
theme: "light",
|
||||
setTheme: (theme) => set({ theme }),
|
||||
|
||||
levelMode: "stacked",
|
||||
setLevelMode: (mode) => set({ levelMode: mode }),
|
||||
|
||||
@@ -109,6 +118,19 @@ const useViewer = create<ViewerState>()(
|
||||
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,
|
||||
setProjectId: (id) =>
|
||||
set((state) => {
|
||||
@@ -118,6 +140,7 @@ const useViewer = create<ViewerState>()(
|
||||
projectId: id,
|
||||
showScans: prefs.showScans ?? true,
|
||||
showGuides: prefs.showGuides ?? true,
|
||||
showGrid: prefs.showGrid ?? true,
|
||||
};
|
||||
}),
|
||||
projectPreferences: {},
|
||||
@@ -165,6 +188,7 @@ const useViewer = create<ViewerState>()(
|
||||
name: 'viewer-preferences',
|
||||
partialize: (state) => ({
|
||||
cameraMode: state.cameraMode,
|
||||
theme: state.theme,
|
||||
levelMode: state.levelMode,
|
||||
wallMode: state.wallMode,
|
||||
projectPreferences: state.projectPreferences,
|
||||
|
||||
Reference in New Issue
Block a user