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:
Aymeric Rabot
2026-02-27 23:49:48 -05:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 5077011c26
commit 98c1d5247e
25 changed files with 2775 additions and 91 deletions
@@ -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} />
</>
)
}