take only building layers to the thumbnail
This commit is contained in:
@@ -3,8 +3,10 @@
|
|||||||
import { type CameraControlEvent, emitter, sceneRegistry, useScene } from '@pascal-app/core'
|
import { type CameraControlEvent, emitter, sceneRegistry, useScene } from '@pascal-app/core'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { CameraControls, CameraControlsImpl } from '@react-three/drei'
|
import { CameraControls, CameraControlsImpl } from '@react-three/drei'
|
||||||
|
import { useThree } from '@react-three/fiber'
|
||||||
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
||||||
import { Vector3 } from 'three'
|
import { Vector3 } from 'three'
|
||||||
|
import { EDITOR_LAYER } from '@/lib/constants'
|
||||||
|
|
||||||
const currentTarget = new Vector3()
|
const currentTarget = new Vector3()
|
||||||
|
|
||||||
@@ -13,6 +15,11 @@ export const CustomCameraControls = () => {
|
|||||||
const currentLevelId = useViewer((state) => state.selection.levelId)
|
const currentLevelId = useViewer((state) => state.selection.levelId)
|
||||||
const firstLoad = useRef(true)
|
const firstLoad = useRef(true)
|
||||||
|
|
||||||
|
const camera = useThree((state) => state.camera)
|
||||||
|
useEffect(() => {
|
||||||
|
camera.layers.enable(EDITOR_LAYER)
|
||||||
|
}, [camera])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let targetY = 0
|
let targetY = 0
|
||||||
if (currentLevelId) {
|
if (currentLevelId) {
|
||||||
|
|||||||
@@ -2,14 +2,13 @@
|
|||||||
|
|
||||||
import { emitter, type GridEvent, sceneRegistry } from '@pascal-app/core'
|
import { emitter, type GridEvent, sceneRegistry } from '@pascal-app/core'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
|
|
||||||
import { useFrame } from '@react-three/fiber'
|
import { useFrame } from '@react-three/fiber'
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { MathUtils, type Mesh, Vector2 } from 'three'
|
import { MathUtils, type Mesh, Vector2 } from 'three'
|
||||||
|
|
||||||
import { color, float, fract, fwidth, mix, positionLocal, uniform } from 'three/tsl'
|
import { color, float, fract, fwidth, mix, positionLocal, uniform } from 'three/tsl'
|
||||||
import { MeshBasicNodeMaterial } from 'three/webgpu'
|
import { MeshBasicNodeMaterial } from 'three/webgpu'
|
||||||
import { useGridEvents } from '@/hooks/use-grid-events'
|
import { useGridEvents } from '@/hooks/use-grid-events'
|
||||||
|
import { EDITOR_LAYER } from '@/lib/constants'
|
||||||
|
|
||||||
export const Grid = ({
|
export const Grid = ({
|
||||||
cellSize = 0.5,
|
cellSize = 0.5,
|
||||||
@@ -111,8 +110,7 @@ export const Grid = ({
|
|||||||
effectiveSectionColor,
|
effectiveSectionColor,
|
||||||
fadeDistance,
|
fadeDistance,
|
||||||
fadeStrength,
|
fadeStrength,
|
||||||
revealRadius,
|
revealRadius
|
||||||
theme,
|
|
||||||
])
|
])
|
||||||
|
|
||||||
const gridRef = useRef<Mesh>(null!)
|
const gridRef = useRef<Mesh>(null!)
|
||||||
@@ -150,7 +148,7 @@ export const Grid = ({
|
|||||||
const showGrid = useViewer((state) => state.showGrid)
|
const showGrid = useViewer((state) => state.showGrid)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<mesh rotation-x={-Math.PI / 2} material={material} ref={gridRef} visible={showGrid}>
|
<mesh rotation-x={-Math.PI / 2} material={material} ref={gridRef} visible={showGrid} layers={EDITOR_LAYER}>
|
||||||
<planeGeometry args={[fadeDistance * 2, fadeDistance * 2]} />
|
<planeGeometry args={[fadeDistance * 2, fadeDistance * 2]} />
|
||||||
</mesh>
|
</mesh>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { emitter, useScene } from '@pascal-app/core'
|
import { emitter, useScene } from '@pascal-app/core'
|
||||||
|
import { snapLevelsToTruePositions } from '@pascal-app/viewer'
|
||||||
import { useThree } from '@react-three/fiber'
|
import { useThree } from '@react-three/fiber'
|
||||||
import { useCallback, useEffect, useRef } from 'react'
|
import { useCallback, 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'
|
import { useProjectStore } from '@/features/community/lib/projects/store'
|
||||||
|
import { EDITOR_LAYER } from '@/lib/constants'
|
||||||
|
|
||||||
const THUMBNAIL_WIDTH = 1920
|
const THUMBNAIL_WIDTH = 1920
|
||||||
const THUMBNAIL_HEIGHT = 1080
|
const THUMBNAIL_HEIGHT = 1080
|
||||||
@@ -46,14 +48,18 @@ export const ThumbnailGenerator = ({ projectId: propProjectId }: ThumbnailGenera
|
|||||||
thumbnailCamera.position.set(8, 8, 8)
|
thumbnailCamera.position.set(8, 8, 8)
|
||||||
thumbnailCamera.lookAt(0, 0, 0)
|
thumbnailCamera.lookAt(0, 0, 0)
|
||||||
}
|
}
|
||||||
|
thumbnailCamera.layers.disable(EDITOR_LAYER) // Render only default layer to exclude helper visuals
|
||||||
|
|
||||||
// Match camera aspect to current canvas so the render looks correct
|
// Match camera aspect to current canvas so the render looks correct
|
||||||
const { width, height } = gl.domElement
|
const { width, height } = gl.domElement
|
||||||
thumbnailCamera.aspect = width / height
|
thumbnailCamera.aspect = width / height
|
||||||
thumbnailCamera.updateProjectionMatrix()
|
thumbnailCamera.updateProjectionMatrix()
|
||||||
|
|
||||||
// Render with thumbnail camera — main canvas is never resized
|
// Snap levels to true stacked positions so the thumbnail always shows a clean view,
|
||||||
|
// regardless of the current levelMode (exploded, solo, etc.)
|
||||||
|
const restoreLevels = snapLevelsToTruePositions()
|
||||||
gl.render(scene, thumbnailCamera)
|
gl.render(scene, thumbnailCamera)
|
||||||
|
restoreLevels()
|
||||||
|
|
||||||
// Center-crop the canvas to the thumbnail aspect ratio, then scale — avoids deformation
|
// Center-crop the canvas to the thumbnail aspect ratio, then scale — avoids deformation
|
||||||
const srcAspect = width / height
|
const srcAspect = width / height
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { emitter, type GridEvent, sceneRegistry } from '@pascal-app/core'
|
|||||||
import { createPortal } from '@react-three/fiber'
|
import { createPortal } from '@react-three/fiber'
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { BufferGeometry, Float32BufferAttribute, type Mesh } from 'three'
|
import { BufferGeometry, Float32BufferAttribute, type Mesh } from 'three'
|
||||||
|
import { EDITOR_LAYER } from '@/lib/constants'
|
||||||
import { sfxEmitter } from '@/lib/sfx-bus'
|
import { sfxEmitter } from '@/lib/sfx-bus'
|
||||||
|
|
||||||
const Y_OFFSET = 0.02
|
const Y_OFFSET = 0.02
|
||||||
@@ -212,10 +213,10 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
|||||||
const canDelete = displayPolygon.length > minVertices
|
const canDelete = displayPolygon.length > minVertices
|
||||||
|
|
||||||
const editorContent = (
|
const editorContent = (
|
||||||
<group>
|
<group >
|
||||||
{/* Border line */}
|
{/* Border line */}
|
||||||
{/* @ts-ignore */}
|
{/* @ts-ignore */}
|
||||||
<line ref={lineRef} frustumCulled={false} renderOrder={10} raycast={() => {}}>
|
<line ref={lineRef} frustumCulled={false} renderOrder={10} raycast={() => {}} layers={EDITOR_LAYER}>
|
||||||
<bufferGeometry />
|
<bufferGeometry />
|
||||||
<lineBasicNodeMaterial
|
<lineBasicNodeMaterial
|
||||||
color={color}
|
color={color}
|
||||||
@@ -236,6 +237,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<mesh
|
<mesh
|
||||||
|
layers={EDITOR_LAYER}
|
||||||
key={`vertex-${index}`}
|
key={`vertex-${index}`}
|
||||||
position={[x!, editY + height / 2, z!]}
|
position={[x!, editY + height / 2, z!]}
|
||||||
castShadow
|
castShadow
|
||||||
@@ -286,6 +288,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<mesh
|
<mesh
|
||||||
|
layers={EDITOR_LAYER}
|
||||||
key={`midpoint-${index}`}
|
key={`midpoint-${index}`}
|
||||||
position={[x!, editY + height / 2, z!]}
|
position={[x!, editY + height / 2, z!]}
|
||||||
onPointerEnter={(e) => {
|
onPointerEnter={(e) => {
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
/** Three.js layer used for editor-only objects (helpers, grid, polygon editors).
|
||||||
|
* The thumbnail camera renders only layer 0, so these are excluded from thumbnails. */
|
||||||
|
export const EDITOR_LAYER = 1
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
import { OrthographicCamera, PerspectiveCamera } from "@react-three/drei"
|
import { OrthographicCamera, PerspectiveCamera } from '@react-three/drei'
|
||||||
import useViewer from "../../store/use-viewer"
|
import useViewer from '../../store/use-viewer'
|
||||||
|
|
||||||
export const ViewerCamera = () => {
|
export const ViewerCamera = () => {
|
||||||
const cameraMode = useViewer((state) => state.cameraMode)
|
const cameraMode = useViewer((state) => state.cameraMode)
|
||||||
|
|
||||||
return cameraMode === 'perspective' ? (
|
return cameraMode === 'perspective' ? (
|
||||||
<PerspectiveCamera far={1000} fov={50} makeDefault near={0.1} position={[10, 10, 10]} />
|
<PerspectiveCamera far={1000} fov={50} makeDefault near={0.1} position={[10, 10, 10]} />
|
||||||
) : (
|
) : (
|
||||||
<OrthographicCamera far={1000} makeDefault near={-1000} position={[10, 10, 10]} zoom={20} />
|
<OrthographicCamera far={1000} makeDefault near={-1000} position={[10, 10, 10]} zoom={20} />
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
export { default as Viewer } from './components/viewer'
|
export { default as Viewer } from './components/viewer'
|
||||||
export { default as useViewer } from './store/use-viewer'
|
export { default as useViewer } from './store/use-viewer'
|
||||||
export { ASSETS_CDN_URL, resolveAssetUrl, resolveCdnUrl } from './lib/asset-url'
|
export { ASSETS_CDN_URL, resolveAssetUrl, resolveCdnUrl } from './lib/asset-url'
|
||||||
|
export { snapLevelsToTruePositions } from './systems/level/level-utils'
|
||||||
@@ -1,62 +1,14 @@
|
|||||||
import { type CeilingNode, type LevelNode, sceneRegistry, useScene, type WallNode } from '@pascal-app/core'
|
import { type LevelNode, sceneRegistry, useScene } from '@pascal-app/core'
|
||||||
import { useFrame } from '@react-three/fiber'
|
import { useFrame } from '@react-three/fiber'
|
||||||
import { lerp } from 'three/src/math/MathUtils.js'
|
import { lerp } from 'three/src/math/MathUtils.js'
|
||||||
import useViewer from '../../store/use-viewer'
|
import useViewer from '../../store/use-viewer'
|
||||||
|
import { getLevelHeight } from './level-utils'
|
||||||
|
|
||||||
const DEFAULT_LEVEL_HEIGHT = 2.5
|
|
||||||
const EXPLODED_GAP = 5
|
const EXPLODED_GAP = 5
|
||||||
|
|
||||||
// Cache: levelId → computed height. Invalidated by nodes reference change.
|
|
||||||
// Zustand produces a new `nodes` object on every mutation, so reference equality
|
|
||||||
// is a zero-cost way to detect stale data without any subscription overhead.
|
|
||||||
const heightCache = new Map<string, number>()
|
|
||||||
let lastNodesRef: object | null = null
|
|
||||||
|
|
||||||
function getLevelHeight(
|
|
||||||
levelId: string,
|
|
||||||
nodes: ReturnType<typeof useScene.getState>['nodes'],
|
|
||||||
): number {
|
|
||||||
if (heightCache.has(levelId)) return heightCache.get(levelId)!
|
|
||||||
|
|
||||||
const level = nodes[levelId as LevelNode['id']] as LevelNode | undefined
|
|
||||||
if (!level) return DEFAULT_LEVEL_HEIGHT
|
|
||||||
|
|
||||||
let maxTop = 0
|
|
||||||
|
|
||||||
for (const childId of level.children) {
|
|
||||||
const child = nodes[childId as keyof typeof nodes]
|
|
||||||
if (!child) continue
|
|
||||||
if (child.type === 'ceiling') {
|
|
||||||
// ceiling.height is the interior face Y in level-local space
|
|
||||||
const ch = (child as CeilingNode).height ?? DEFAULT_LEVEL_HEIGHT
|
|
||||||
if (ch > maxTop) maxTop = ch
|
|
||||||
} else if (child.type === 'wall') {
|
|
||||||
// Wall mesh is pushed up to slabElevation by WallSystem.
|
|
||||||
// mesh.position.y + wall.height gives the actual top Y in level-local space.
|
|
||||||
let meshY = sceneRegistry.nodes.get(childId as any)?.position.y ?? 0
|
|
||||||
if (meshY < 0) {
|
|
||||||
meshY = 0 // Guard against invalid negative Y which could cause incorrect height calculation (e.g. from sunken slabs)
|
|
||||||
}
|
|
||||||
const top = meshY + ((child as WallNode).height ?? DEFAULT_LEVEL_HEIGHT)
|
|
||||||
if (top > maxTop) maxTop = top
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const height = maxTop > 0 ? maxTop : DEFAULT_LEVEL_HEIGHT
|
|
||||||
heightCache.set(levelId, height)
|
|
||||||
return height
|
|
||||||
}
|
|
||||||
|
|
||||||
export const LevelSystem = () => {
|
export const LevelSystem = () => {
|
||||||
useFrame((_, delta) => {
|
useFrame((_, delta) => {
|
||||||
const nodes = useScene.getState().nodes
|
const nodes = useScene.getState().nodes
|
||||||
|
|
||||||
// Clear cache when nodes reference changes (any node was mutated)
|
|
||||||
if (nodes !== lastNodesRef) {
|
|
||||||
heightCache.clear()
|
|
||||||
lastNodesRef = nodes
|
|
||||||
}
|
|
||||||
|
|
||||||
const levelMode = useViewer.getState().levelMode
|
const levelMode = useViewer.getState().levelMode
|
||||||
const selectedLevel = useViewer.getState().selection.levelId
|
const selectedLevel = useViewer.getState().selection.levelId
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { type CeilingNode, type LevelNode, sceneRegistry, useScene, type WallNode } from '@pascal-app/core'
|
||||||
|
|
||||||
|
export const DEFAULT_LEVEL_HEIGHT = 2.5
|
||||||
|
|
||||||
|
// Cache: levelId → computed height. Invalidated when the nodes reference changes.
|
||||||
|
// Zustand produces a new `nodes` object on every mutation, so reference equality
|
||||||
|
// is a zero-cost way to detect stale data without any subscription overhead.
|
||||||
|
const heightCache = new Map<string, number>()
|
||||||
|
let lastNodesRef: object | null = null
|
||||||
|
|
||||||
|
export function getLevelHeight(
|
||||||
|
levelId: string,
|
||||||
|
nodes: ReturnType<typeof useScene.getState>['nodes'],
|
||||||
|
): number {
|
||||||
|
if (nodes !== lastNodesRef) {
|
||||||
|
heightCache.clear()
|
||||||
|
lastNodesRef = nodes
|
||||||
|
}
|
||||||
|
|
||||||
|
if (heightCache.has(levelId)) return heightCache.get(levelId)!
|
||||||
|
|
||||||
|
const level = nodes[levelId as LevelNode['id']] as LevelNode | undefined
|
||||||
|
if (!level) return DEFAULT_LEVEL_HEIGHT
|
||||||
|
|
||||||
|
let maxTop = 0
|
||||||
|
|
||||||
|
for (const childId of level.children) {
|
||||||
|
const child = nodes[childId as keyof typeof nodes]
|
||||||
|
if (!child) continue
|
||||||
|
if (child.type === 'ceiling') {
|
||||||
|
const ch = (child as CeilingNode).height ?? DEFAULT_LEVEL_HEIGHT
|
||||||
|
if (ch > maxTop) maxTop = ch
|
||||||
|
} else if (child.type === 'wall') {
|
||||||
|
let meshY = sceneRegistry.nodes.get(childId as any)?.position.y ?? 0
|
||||||
|
if (meshY < 0) meshY = 0
|
||||||
|
const top = meshY + ((child as WallNode).height ?? DEFAULT_LEVEL_HEIGHT)
|
||||||
|
if (top > maxTop) maxTop = top
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const height = maxTop > 0 ? maxTop : DEFAULT_LEVEL_HEIGHT
|
||||||
|
heightCache.set(levelId, height)
|
||||||
|
return height
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Instantly snaps all level Objects3D to their true stacked Y positions
|
||||||
|
* (ignores levelMode — always uses stacked, no exploded gap).
|
||||||
|
*
|
||||||
|
* Returns a restore function that reverts each level's Y to what it was
|
||||||
|
* before the snap, so lerp animations in LevelSystem can continue undisturbed.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* const restore = snapLevelsToTruePositions()
|
||||||
|
* renderer.render(scene, camera)
|
||||||
|
* restore()
|
||||||
|
*/
|
||||||
|
export function snapLevelsToTruePositions(): () => void {
|
||||||
|
const nodes = useScene.getState().nodes
|
||||||
|
|
||||||
|
type LevelEntry = {
|
||||||
|
obj: NonNullable<ReturnType<typeof sceneRegistry.nodes.get>>
|
||||||
|
levelId: string
|
||||||
|
index: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries: LevelEntry[] = []
|
||||||
|
sceneRegistry.byType.level.forEach((levelId) => {
|
||||||
|
const obj = sceneRegistry.nodes.get(levelId)
|
||||||
|
const level = nodes[levelId as LevelNode['id']]
|
||||||
|
if (obj && level) {
|
||||||
|
entries.push({ levelId, index: (level as any).level ?? 0, obj })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
entries.sort((a, b) => a.index - b.index)
|
||||||
|
|
||||||
|
// Snapshot current Y and visibility so we can restore them after the render
|
||||||
|
const snapshot = new Map(entries.map(({ levelId, obj }) => [levelId, { y: obj.position.y, visible: obj.visible }]))
|
||||||
|
|
||||||
|
// Snap to true stacked positions and make all levels visible
|
||||||
|
let cumulativeY = 0
|
||||||
|
for (const { levelId, obj } of entries) {
|
||||||
|
obj.position.y = cumulativeY
|
||||||
|
obj.visible = true
|
||||||
|
cumulativeY += getLevelHeight(levelId, nodes)
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
for (const { levelId, obj } of entries) {
|
||||||
|
const saved = snapshot.get(levelId)
|
||||||
|
if (saved !== undefined) {
|
||||||
|
obj.position.y = saved.y
|
||||||
|
obj.visible = saved.visible
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user