From 0f271985d85403f74a49c526f6f2fe23b46ea9a5 Mon Sep 17 00:00:00 2001 From: wass08 Date: Mon, 2 Mar 2026 10:01:22 +0900 Subject: [PATCH] take only building layers to the thumbnail --- .../editor/custom-camera-controls.tsx | 7 ++ apps/editor/components/editor/grid.tsx | 24 +++-- .../components/editor/thumbnail-generator.tsx | 8 +- .../tools/shared/polygon-editor.tsx | 7 +- apps/editor/lib/constants.ts | 3 + .../src/components/viewer/viewer-camera.tsx | 15 +-- packages/viewer/src/index.ts | 3 +- .../viewer/src/systems/level/level-system.tsx | 52 +--------- .../viewer/src/systems/level/level-utils.ts | 97 +++++++++++++++++++ 9 files changed, 142 insertions(+), 74 deletions(-) create mode 100644 apps/editor/lib/constants.ts create mode 100644 packages/viewer/src/systems/level/level-utils.ts diff --git a/apps/editor/components/editor/custom-camera-controls.tsx b/apps/editor/components/editor/custom-camera-controls.tsx index a08fc54b..1878a113 100644 --- a/apps/editor/components/editor/custom-camera-controls.tsx +++ b/apps/editor/components/editor/custom-camera-controls.tsx @@ -3,8 +3,10 @@ import { type CameraControlEvent, emitter, sceneRegistry, useScene } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { CameraControls, CameraControlsImpl } from '@react-three/drei' +import { useThree } from '@react-three/fiber' import { useCallback, useEffect, useMemo, useRef } from 'react' import { Vector3 } from 'three' +import { EDITOR_LAYER } from '@/lib/constants' const currentTarget = new Vector3() @@ -13,6 +15,11 @@ export const CustomCameraControls = () => { const currentLevelId = useViewer((state) => state.selection.levelId) const firstLoad = useRef(true) + const camera = useThree((state) => state.camera) + useEffect(() => { + camera.layers.enable(EDITOR_LAYER) + }, [camera]) + useEffect(() => { let targetY = 0 if (currentLevelId) { diff --git a/apps/editor/components/editor/grid.tsx b/apps/editor/components/editor/grid.tsx index d2577285..bcddba2d 100644 --- a/apps/editor/components/editor/grid.tsx +++ b/apps/editor/components/editor/grid.tsx @@ -2,14 +2,13 @@ import { emitter, type GridEvent, sceneRegistry } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' - import { useFrame } from '@react-three/fiber' import { useEffect, useMemo, useRef, useState } from 'react' import { MathUtils, type Mesh, Vector2 } from 'three' - import { color, float, fract, fwidth, mix, positionLocal, uniform } from 'three/tsl' import { MeshBasicNodeMaterial } from 'three/webgpu' import { useGridEvents } from '@/hooks/use-grid-events' +import { EDITOR_LAYER } from '@/lib/constants' export const Grid = ({ cellSize = 0.5, @@ -103,16 +102,15 @@ export const Grid = ({ depthWrite: false, }) }, [ - cellSize, - cellThickness, - effectiveCellColor, - sectionSize, - sectionThickness, - effectiveSectionColor, - fadeDistance, - fadeStrength, - revealRadius, - theme, + cellSize, + cellThickness, + effectiveCellColor, + sectionSize, + sectionThickness, + effectiveSectionColor, + fadeDistance, + fadeStrength, + revealRadius ]) const gridRef = useRef(null!) @@ -150,7 +148,7 @@ export const Grid = ({ const showGrid = useViewer((state) => state.showGrid) return ( - + ) diff --git a/apps/editor/components/editor/thumbnail-generator.tsx b/apps/editor/components/editor/thumbnail-generator.tsx index 02a84d6b..2445a1a8 100644 --- a/apps/editor/components/editor/thumbnail-generator.tsx +++ b/apps/editor/components/editor/thumbnail-generator.tsx @@ -1,11 +1,13 @@ 'use client' import { emitter, useScene } from '@pascal-app/core' +import { snapLevelsToTruePositions } from '@pascal-app/viewer' import { useThree } from '@react-three/fiber' import { useCallback, useEffect, useRef } from 'react' import * as THREE from 'three' import { uploadProjectThumbnail } from '@/features/community/lib/projects/actions' import { useProjectStore } from '@/features/community/lib/projects/store' +import { EDITOR_LAYER } from '@/lib/constants' const THUMBNAIL_WIDTH = 1920 const THUMBNAIL_HEIGHT = 1080 @@ -46,14 +48,18 @@ export const ThumbnailGenerator = ({ projectId: propProjectId }: ThumbnailGenera thumbnailCamera.position.set(8, 8, 8) 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 const { width, height } = gl.domElement thumbnailCamera.aspect = width / height 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) + restoreLevels() // Center-crop the canvas to the thumbnail aspect ratio, then scale — avoids deformation const srcAspect = width / height diff --git a/apps/editor/components/tools/shared/polygon-editor.tsx b/apps/editor/components/tools/shared/polygon-editor.tsx index dc038d10..e42cd0b3 100644 --- a/apps/editor/components/tools/shared/polygon-editor.tsx +++ b/apps/editor/components/tools/shared/polygon-editor.tsx @@ -2,6 +2,7 @@ import { emitter, type GridEvent, sceneRegistry } from '@pascal-app/core' import { createPortal } from '@react-three/fiber' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { BufferGeometry, Float32BufferAttribute, type Mesh } from 'three' +import { EDITOR_LAYER } from '@/lib/constants' import { sfxEmitter } from '@/lib/sfx-bus' const Y_OFFSET = 0.02 @@ -212,10 +213,10 @@ export const PolygonEditor: React.FC = ({ const canDelete = displayPolygon.length > minVertices const editorContent = ( - + {/* Border line */} {/* @ts-ignore */} - {}}> + {}} layers={EDITOR_LAYER}> = ({ return ( = ({ return ( { diff --git a/apps/editor/lib/constants.ts b/apps/editor/lib/constants.ts new file mode 100644 index 00000000..a2d0fdde --- /dev/null +++ b/apps/editor/lib/constants.ts @@ -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 diff --git a/packages/viewer/src/components/viewer/viewer-camera.tsx b/packages/viewer/src/components/viewer/viewer-camera.tsx index 20d7ccaa..adb24e9f 100644 --- a/packages/viewer/src/components/viewer/viewer-camera.tsx +++ b/packages/viewer/src/components/viewer/viewer-camera.tsx @@ -1,11 +1,12 @@ -import { OrthographicCamera, PerspectiveCamera } from "@react-three/drei" -import useViewer from "../../store/use-viewer" +import { OrthographicCamera, PerspectiveCamera } from '@react-three/drei' +import useViewer from '../../store/use-viewer' export const ViewerCamera = () => { const cameraMode = useViewer((state) => state.cameraMode) + return cameraMode === 'perspective' ? ( - - ) : ( - - ) -} \ No newline at end of file + + ) : ( + + ) +} diff --git a/packages/viewer/src/index.ts b/packages/viewer/src/index.ts index 2c417fbe..ca2f7a93 100644 --- a/packages/viewer/src/index.ts +++ b/packages/viewer/src/index.ts @@ -1,3 +1,4 @@ export { default as Viewer } from './components/viewer' export { default as useViewer } from './store/use-viewer' -export { ASSETS_CDN_URL, resolveAssetUrl, resolveCdnUrl } from './lib/asset-url' \ No newline at end of file +export { ASSETS_CDN_URL, resolveAssetUrl, resolveCdnUrl } from './lib/asset-url' +export { snapLevelsToTruePositions } from './systems/level/level-utils' \ No newline at end of file diff --git a/packages/viewer/src/systems/level/level-system.tsx b/packages/viewer/src/systems/level/level-system.tsx index f6a9bfd3..aabbcd25 100644 --- a/packages/viewer/src/systems/level/level-system.tsx +++ b/packages/viewer/src/systems/level/level-system.tsx @@ -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 { lerp } from 'three/src/math/MathUtils.js' import useViewer from '../../store/use-viewer' +import { getLevelHeight } from './level-utils' -const DEFAULT_LEVEL_HEIGHT = 2.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() -let lastNodesRef: object | null = null - -function getLevelHeight( - levelId: string, - nodes: ReturnType['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 = () => { useFrame((_, delta) => { 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 selectedLevel = useViewer.getState().selection.levelId diff --git a/packages/viewer/src/systems/level/level-utils.ts b/packages/viewer/src/systems/level/level-utils.ts new file mode 100644 index 00000000..ee8d9d4c --- /dev/null +++ b/packages/viewer/src/systems/level/level-utils.ts @@ -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() +let lastNodesRef: object | null = null + +export function getLevelHeight( + levelId: string, + nodes: ReturnType['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> + 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 + } + } + } +}