Merge pull request #129 from pascalorg/fix/thumbnails-and-doors
Fix/thumbnails and doors
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
@@ -111,8 +110,7 @@ export const Grid = ({
|
||||
effectiveSectionColor,
|
||||
fadeDistance,
|
||||
fadeStrength,
|
||||
revealRadius,
|
||||
theme,
|
||||
revealRadius
|
||||
])
|
||||
|
||||
const gridRef = useRef<Mesh>(null!)
|
||||
@@ -150,7 +148,7 @@ export const Grid = ({
|
||||
const showGrid = useViewer((state) => state.showGrid)
|
||||
|
||||
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]} />
|
||||
</mesh>
|
||||
)
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
'use client'
|
||||
|
||||
import { emitter, useScene } from '@pascal-app/core'
|
||||
import { emitter, sceneRegistry, 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,15 +48,37 @@ 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()
|
||||
|
||||
// Hide guides and scans — they are reference overlays, not part of the architectural model
|
||||
const visibilitySnapshot = new Map<string, boolean>()
|
||||
for (const type of ['scan', 'guide'] as const) {
|
||||
sceneRegistry.byType[type].forEach((id) => {
|
||||
const obj = sceneRegistry.nodes.get(id)
|
||||
if (obj) {
|
||||
visibilitySnapshot.set(id, obj.visible)
|
||||
obj.visible = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
gl.render(scene, thumbnailCamera)
|
||||
|
||||
restoreLevels()
|
||||
visibilitySnapshot.forEach((wasVisible, id) => {
|
||||
const obj = sceneRegistry.nodes.get(id)
|
||||
if (obj) obj.visible = wasVisible
|
||||
})
|
||||
|
||||
// Center-crop the canvas to the thumbnail aspect ratio, then scale — avoids deformation
|
||||
const srcAspect = width / height
|
||||
const dstAspect = THUMBNAIL_WIDTH / THUMBNAIL_HEIGHT
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { BufferGeometry, DoubleSide, type Line, type Group, Shape, Vector3 } from 'three'
|
||||
import { mix, positionLocal } from 'three/tsl'
|
||||
import { EDITOR_LAYER } from '@/lib/constants'
|
||||
import { sfxEmitter } from '@/lib/sfx-bus'
|
||||
import { CursorSphere } from '../shared/cursor-sphere'
|
||||
|
||||
@@ -295,14 +296,14 @@ export const CeilingTool: React.FC = () => {
|
||||
<CursorSphere ref={cursorRef} />
|
||||
|
||||
{/* Grid-level cursor indicator */}
|
||||
<mesh ref={gridCursorRef} rotation={[-Math.PI / 2, 0, 0]} renderOrder={2}>
|
||||
<mesh ref={gridCursorRef} rotation={[-Math.PI / 2, 0, 0]} renderOrder={2} layers={EDITOR_LAYER}>
|
||||
<ringGeometry args={[0.15, 0.2, 32]} />
|
||||
<meshBasicMaterial color="#818cf8" side={DoubleSide} depthTest={false} depthWrite={true} opacity={0.5} transparent />
|
||||
</mesh>
|
||||
|
||||
{/* Vertical connector: local y=0 at grid, y=H at ceiling; position.y set to gridY on move */}
|
||||
{/* @ts-ignore */}
|
||||
<line ref={verticalLineRef} geometry={verticalGeo} renderOrder={1}>
|
||||
<line ref={verticalLineRef} geometry={verticalGeo} renderOrder={1} layers={EDITOR_LAYER}>
|
||||
<lineBasicNodeMaterial color="#818cf8" opacityNode={gradientOpacityNode} depthTest={false} depthWrite={false} transparent />
|
||||
</line>
|
||||
|
||||
@@ -310,6 +311,7 @@ export const CeilingTool: React.FC = () => {
|
||||
{previewShape && (
|
||||
<mesh
|
||||
frustumCulled={false}
|
||||
layers={EDITOR_LAYER}
|
||||
position={[0, levelY + CEILING_HEIGHT, 0]}
|
||||
rotation={[-Math.PI / 2, 0, 0]}
|
||||
>
|
||||
@@ -328,6 +330,7 @@ export const CeilingTool: React.FC = () => {
|
||||
{previewShape && (
|
||||
<mesh
|
||||
frustumCulled={false}
|
||||
layers={EDITOR_LAYER}
|
||||
position={[0, levelY + GRID_OFFSET, 0]}
|
||||
rotation={[-Math.PI / 2, 0, 0]}
|
||||
>
|
||||
@@ -344,14 +347,14 @@ export const CeilingTool: React.FC = () => {
|
||||
|
||||
{/* Main line */}
|
||||
{/* @ts-ignore */}
|
||||
<line ref={mainLineRef} frustumCulled={false} renderOrder={1} visible={false}>
|
||||
<line ref={mainLineRef} frustumCulled={false} renderOrder={1} visible={false} layers={EDITOR_LAYER}>
|
||||
<bufferGeometry />
|
||||
<lineBasicNodeMaterial color="#818cf8" linewidth={3} depthTest={false} depthWrite={false} />
|
||||
</line>
|
||||
|
||||
{/* Closing line */}
|
||||
{/* @ts-ignore */}
|
||||
<line ref={closingLineRef} frustumCulled={false} renderOrder={1} visible={false}>
|
||||
<line ref={closingLineRef} frustumCulled={false} renderOrder={1} visible={false} layers={EDITOR_LAYER}>
|
||||
<bufferGeometry />
|
||||
<lineBasicNodeMaterial
|
||||
color="#818cf8"
|
||||
@@ -365,14 +368,14 @@ export const CeilingTool: React.FC = () => {
|
||||
|
||||
{/* Ground main line */}
|
||||
{/* @ts-ignore */}
|
||||
<line ref={groundMainLineRef} frustumCulled={false} renderOrder={1} visible={false}>
|
||||
<line ref={groundMainLineRef} frustumCulled={false} renderOrder={1} visible={false} layers={EDITOR_LAYER}>
|
||||
<bufferGeometry />
|
||||
<lineBasicNodeMaterial color="#818cf8" linewidth={3} depthTest={false} depthWrite={false} opacity={0.3} transparent />
|
||||
</line>
|
||||
|
||||
{/* Ground closing line */}
|
||||
{/* @ts-ignore */}
|
||||
<line ref={groundClosingLineRef} frustumCulled={false} renderOrder={1} visible={false}>
|
||||
<line ref={groundClosingLineRef} frustumCulled={false} renderOrder={1} visible={false} layers={EDITOR_LAYER}>
|
||||
<bufferGeometry />
|
||||
<lineBasicNodeMaterial
|
||||
color="#818cf8"
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
snapToHalf,
|
||||
} from '../item/placement-math'
|
||||
import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './door-math'
|
||||
import { EDITOR_LAYER } from '@/lib/constants'
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
|
||||
const edgeMaterial = new LineBasicNodeMaterial({
|
||||
@@ -260,7 +261,7 @@ export const DoorTool: React.FC = () => {
|
||||
|
||||
return (
|
||||
<group ref={cursorGroupRef} visible={false}>
|
||||
<lineSegments ref={edgesRef} geometry={edgesGeo} material={edgeMaterial} />
|
||||
<lineSegments ref={edgesRef} geometry={edgesGeo} material={edgeMaterial} layers={EDITOR_LAYER} />
|
||||
</group>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect, useMemo, useRef } from 'react'
|
||||
import { BoxGeometry, EdgesGeometry, type Group } from 'three'
|
||||
import { LineBasicNodeMaterial } from 'three/webgpu'
|
||||
import { EDITOR_LAYER } from '@/lib/constants'
|
||||
import { sfxEmitter } from '@/lib/sfx-bus'
|
||||
import useEditor from '@/store/use-editor'
|
||||
import {
|
||||
@@ -204,27 +205,15 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod
|
||||
useScene.getState().deleteNode(movingDoorNode.id)
|
||||
useScene.temporal.getState().resume()
|
||||
|
||||
const cloned = structuredClone(movingDoorNode) as any
|
||||
delete cloned.id
|
||||
const node = DoorNode.parse({
|
||||
...cloned,
|
||||
position: [clampedX, clampedY, 0],
|
||||
rotation: [0, itemRotation, 0],
|
||||
side,
|
||||
wallId: event.node.id,
|
||||
parentId: event.node.id,
|
||||
width: movingDoorNode.width,
|
||||
height: movingDoorNode.height,
|
||||
frameThickness: movingDoorNode.frameThickness,
|
||||
frameDepth: movingDoorNode.frameDepth,
|
||||
threshold: movingDoorNode.threshold,
|
||||
thresholdHeight: movingDoorNode.thresholdHeight,
|
||||
hingesSide: movingDoorNode.hingesSide,
|
||||
swingDirection: movingDoorNode.swingDirection,
|
||||
segments: movingDoorNode.segments,
|
||||
handle: movingDoorNode.handle,
|
||||
handleHeight: movingDoorNode.handleHeight,
|
||||
handleSide: movingDoorNode.handleSide,
|
||||
doorCloser: movingDoorNode.doorCloser,
|
||||
panicBar: movingDoorNode.panicBar,
|
||||
panicBarHeight: movingDoorNode.panicBarHeight,
|
||||
})
|
||||
useScene.getState().createNode(node, event.node.id as AnyNodeId)
|
||||
placedId = node.id
|
||||
@@ -348,7 +337,7 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod
|
||||
|
||||
return (
|
||||
<group ref={cursorGroupRef} visible={false}>
|
||||
<lineSegments geometry={edgesGeo} material={edgeMaterial} />
|
||||
<lineSegments geometry={edgesGeo} material={edgeMaterial} layers={EDITOR_LAYER} />
|
||||
</group>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
} from 'three'
|
||||
import { distance, smoothstep, uv, vec2 } from 'three/tsl'
|
||||
import { LineBasicNodeMaterial, MeshBasicNodeMaterial } from 'three/webgpu'
|
||||
import { EDITOR_LAYER } from '@/lib/constants'
|
||||
import { sfxEmitter } from '@/lib/sfx-bus'
|
||||
import { ceilingStrategy, checkCanPlace, floorStrategy, itemSurfaceStrategy, wallStrategy } from './placement-strategies'
|
||||
import type { PlacementState, TransitionResult } from './placement-types'
|
||||
@@ -759,10 +760,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
||||
|
||||
return (
|
||||
<group ref={cursorGroupRef}>
|
||||
<lineSegments ref={edgesRef} material={edgeMaterial}>
|
||||
<lineSegments ref={edgesRef} material={edgeMaterial} layers={EDITOR_LAYER}>
|
||||
<edgesGeometry args={[initialBoxGeometry]} />
|
||||
</lineSegments>
|
||||
<mesh ref={basePlaneRef} geometry={basePlaneGeometry} material={basePlaneMaterial} />
|
||||
<mesh ref={basePlaneRef} geometry={basePlaneGeometry} material={basePlaneMaterial} layers={EDITOR_LAYER} />
|
||||
</group>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { BufferGeometry, DoubleSide, type Line, type Group, Vector3 } from 'three'
|
||||
import { EDITOR_LAYER } from '@/lib/constants'
|
||||
import { sfxEmitter } from '@/lib/sfx-bus'
|
||||
import useEditor from '@/store/use-editor'
|
||||
import { CursorSphere } from '../shared/cursor-sphere'
|
||||
@@ -210,7 +211,7 @@ export const RoofTool: React.FC = () => {
|
||||
|
||||
{/* Outline showing rectangle being drawn (Ground) */}
|
||||
{/* @ts-ignore */}
|
||||
<line ref={outlineRef} frustumCulled={false} renderOrder={1} visible={false}>
|
||||
<line ref={outlineRef} frustumCulled={false} renderOrder={1} visible={false} layers={EDITOR_LAYER}>
|
||||
<bufferGeometry />
|
||||
<lineBasicNodeMaterial color="#818cf8" linewidth={2} depthTest={false} depthWrite={false} opacity={0.3} transparent />
|
||||
</line>
|
||||
@@ -227,6 +228,7 @@ export const RoofTool: React.FC = () => {
|
||||
{/* Thin preview fill when drawing (Ground) */}
|
||||
{previewDimensions && previewDimensions.length > 0.1 && previewDimensions.width > 0.1 && (
|
||||
<mesh
|
||||
layers={EDITOR_LAYER}
|
||||
position={[previewDimensions.centerX, levelY + GRID_OFFSET, previewDimensions.centerZ]}
|
||||
rotation={[-Math.PI / 2, 0, 0]}
|
||||
>
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { ThreeElements } from '@react-three/fiber'
|
||||
import { forwardRef } from 'react'
|
||||
import type { Group } from 'three'
|
||||
import { Html } from '@react-three/drei'
|
||||
import { EDITOR_LAYER } from '@/lib/constants'
|
||||
import useEditor from '@/store/use-editor'
|
||||
import { tools } from '@/components/ui/action-menu/structure-tools'
|
||||
import { furnishTools } from '@/components/ui/action-menu/furnish-tools'
|
||||
@@ -36,13 +37,13 @@ export const CursorSphere = forwardRef<Group, CursorSphereProps>(function Cursor
|
||||
{/* Flat marker on the ground */}
|
||||
<group rotation={[-Math.PI / 2, 0, 0]}>
|
||||
{/* Center dot */}
|
||||
<mesh renderOrder={2}>
|
||||
<mesh renderOrder={2} layers={EDITOR_LAYER}>
|
||||
<circleGeometry args={[0.06, 32]} />
|
||||
<meshBasicMaterial color={color} depthTest={false} depthWrite={false} transparent opacity={0.9} />
|
||||
</mesh>
|
||||
|
||||
{/* Outer ring / glow */}
|
||||
<mesh renderOrder={2}>
|
||||
<mesh renderOrder={2} layers={EDITOR_LAYER}>
|
||||
<circleGeometry args={[0.2, 32]} />
|
||||
<meshBasicMaterial color={color} depthTest={false} depthWrite={false} transparent opacity={0.25} />
|
||||
</mesh>
|
||||
@@ -50,7 +51,7 @@ export const CursorSphere = forwardRef<Group, CursorSphereProps>(function Cursor
|
||||
|
||||
{/* Vertical line */}
|
||||
{height > 0 && (
|
||||
<mesh position={[0, height / 2, 0]} renderOrder={2}>
|
||||
<mesh position={[0, height / 2, 0]} renderOrder={2} layers={EDITOR_LAYER}>
|
||||
<cylinderGeometry args={[0.01, 0.01, height, 8]} />
|
||||
<meshBasicMaterial color={color} depthTest={false} depthWrite={false} transparent opacity={0.7} />
|
||||
</mesh>
|
||||
|
||||
@@ -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<PolygonEditorProps> = ({
|
||||
const canDelete = displayPolygon.length > minVertices
|
||||
|
||||
const editorContent = (
|
||||
<group>
|
||||
<group >
|
||||
{/* Border line */}
|
||||
{/* @ts-ignore */}
|
||||
<line ref={lineRef} frustumCulled={false} renderOrder={10} raycast={() => {}}>
|
||||
<line ref={lineRef} frustumCulled={false} renderOrder={10} raycast={() => {}} layers={EDITOR_LAYER}>
|
||||
<bufferGeometry />
|
||||
<lineBasicNodeMaterial
|
||||
color={color}
|
||||
@@ -236,6 +237,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
||||
|
||||
return (
|
||||
<mesh
|
||||
layers={EDITOR_LAYER}
|
||||
key={`vertex-${index}`}
|
||||
position={[x!, editY + height / 2, z!]}
|
||||
castShadow
|
||||
@@ -286,6 +288,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
||||
|
||||
return (
|
||||
<mesh
|
||||
layers={EDITOR_LAYER}
|
||||
key={`midpoint-${index}`}
|
||||
position={[x!, editY + height / 2, z!]}
|
||||
onPointerEnter={(e) => {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { emitter, type GridEvent, type LevelNode, SlabNode, useScene } from '@pa
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { BufferGeometry, DoubleSide, type Line, type Group, Shape, Vector3 } from 'three'
|
||||
import { EDITOR_LAYER } from '@/lib/constants'
|
||||
import { sfxEmitter } from '@/lib/sfx-bus'
|
||||
import { CursorSphere } from '../shared/cursor-sphere'
|
||||
|
||||
@@ -243,6 +244,7 @@ export const SlabTool: React.FC = () => {
|
||||
{previewShape && (
|
||||
<mesh
|
||||
frustumCulled={false}
|
||||
layers={EDITOR_LAYER}
|
||||
position={[0, levelY + Y_OFFSET, 0]}
|
||||
rotation={[-Math.PI / 2, 0, 0]}
|
||||
>
|
||||
@@ -259,14 +261,14 @@ export const SlabTool: React.FC = () => {
|
||||
|
||||
{/* Main line */}
|
||||
{/* @ts-ignore */}
|
||||
<line ref={mainLineRef} frustumCulled={false} renderOrder={1} visible={false}>
|
||||
<line ref={mainLineRef} frustumCulled={false} renderOrder={1} visible={false} layers={EDITOR_LAYER}>
|
||||
<bufferGeometry />
|
||||
<lineBasicNodeMaterial color="#818cf8" linewidth={3} depthTest={false} depthWrite={false} />
|
||||
</line>
|
||||
|
||||
{/* Closing line */}
|
||||
{/* @ts-ignore */}
|
||||
<line ref={closingLineRef} frustumCulled={false} renderOrder={1} visible={false}>
|
||||
<line ref={closingLineRef} frustumCulled={false} renderOrder={1} visible={false} layers={EDITOR_LAYER}>
|
||||
<bufferGeometry />
|
||||
<lineBasicNodeMaterial
|
||||
color="#818cf8"
|
||||
|
||||
@@ -2,6 +2,7 @@ import { emitter, type GridEvent, useScene, WallNode } from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { DoubleSide, type Mesh, type Group, Shape, ShapeGeometry, Vector3 } from 'three'
|
||||
import { EDITOR_LAYER } from '@/lib/constants'
|
||||
import { sfxEmitter } from '@/lib/sfx-bus'
|
||||
import { CursorSphere } from '../shared/cursor-sphere'
|
||||
|
||||
@@ -198,7 +199,7 @@ export const WallTool: React.FC = () => {
|
||||
<CursorSphere ref={cursorRef} />
|
||||
|
||||
{/* Wall preview */}
|
||||
<mesh ref={wallPreviewRef} visible={false} renderOrder={1}>
|
||||
<mesh ref={wallPreviewRef} visible={false} renderOrder={1} layers={EDITOR_LAYER}>
|
||||
<shapeGeometry />
|
||||
<meshBasicMaterial
|
||||
color="#818cf8"
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect, useMemo, useRef } from 'react'
|
||||
import { BoxGeometry, EdgesGeometry, type Group } from 'three'
|
||||
import { LineBasicNodeMaterial } from 'three/webgpu'
|
||||
import { EDITOR_LAYER } from '@/lib/constants'
|
||||
import { sfxEmitter } from '@/lib/sfx-bus'
|
||||
import useEditor from '@/store/use-editor'
|
||||
import {
|
||||
@@ -370,7 +371,7 @@ export const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWin
|
||||
|
||||
return (
|
||||
<group ref={cursorGroupRef} visible={false}>
|
||||
<lineSegments geometry={edgesGeo} material={edgeMaterial} />
|
||||
<lineSegments geometry={edgesGeo} material={edgeMaterial} layers={EDITOR_LAYER} />
|
||||
</group>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
snapToHalf,
|
||||
} from '../item/placement-math'
|
||||
import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './window-math'
|
||||
import { EDITOR_LAYER } from '@/lib/constants'
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
|
||||
// Shared edge material — reuse across renders, just toggle color
|
||||
@@ -269,7 +270,7 @@ export const WindowTool: React.FC = () => {
|
||||
|
||||
return (
|
||||
<group ref={cursorGroupRef} visible={false}>
|
||||
<lineSegments ref={edgesRef} geometry={edgesGeo} material={edgeMaterial} />
|
||||
<lineSegments ref={edgesRef} geometry={edgesGeo} material={edgeMaterial} layers={EDITOR_LAYER} />
|
||||
</group>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { emitter, type GridEvent, useScene, ZoneNode, type LevelNode } from "@pa
|
||||
import { useViewer } from "@pascal-app/viewer";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { BufferGeometry, DoubleSide, type Line, type Group, Shape, Vector3 } from "three";
|
||||
import { EDITOR_LAYER } from "@/lib/constants";
|
||||
import useEditor from "@/store/use-editor";
|
||||
import { CursorSphere } from "../shared/cursor-sphere";
|
||||
|
||||
@@ -318,6 +319,7 @@ export const ZoneTool: React.FC = () => {
|
||||
{previewShape && (
|
||||
<mesh
|
||||
frustumCulled={false}
|
||||
layers={EDITOR_LAYER}
|
||||
position={[0, levelY + Y_OFFSET, 0]}
|
||||
rotation={[-Math.PI / 2, 0, 0]}
|
||||
>
|
||||
@@ -334,7 +336,7 @@ export const ZoneTool: React.FC = () => {
|
||||
|
||||
{/* Main line - uses native line element with TSL-compatible material */}
|
||||
{/* @ts-ignore */}
|
||||
<line ref={mainLineRef} frustumCulled={false} renderOrder={1} visible={false}>
|
||||
<line ref={mainLineRef} frustumCulled={false} renderOrder={1} visible={false} layers={EDITOR_LAYER}>
|
||||
<bufferGeometry />
|
||||
<lineBasicNodeMaterial
|
||||
color="#818cf8"
|
||||
@@ -346,7 +348,7 @@ export const ZoneTool: React.FC = () => {
|
||||
|
||||
{/* Closing line - uses native line element with TSL-compatible material */}
|
||||
{/* @ts-ignore */}
|
||||
<line ref={closingLineRef} frustumCulled={false} renderOrder={1} visible={false}>
|
||||
<line ref={closingLineRef} frustumCulled={false} renderOrder={1} visible={false} layers={EDITOR_LAYER}>
|
||||
<bufferGeometry />
|
||||
<lineBasicNodeMaterial
|
||||
color="#818cf8"
|
||||
|
||||
@@ -68,29 +68,10 @@ export function DoorPanel() {
|
||||
if (!node || !node.parentId) return
|
||||
sfxEmitter.emit('sfx:item-pick')
|
||||
useScene.temporal.getState().pause()
|
||||
const duplicate = DoorNode.parse({
|
||||
position: [...node.position] as [number, number, number],
|
||||
rotation: [...node.rotation] as [number, number, number],
|
||||
side: node.side,
|
||||
wallId: node.wallId,
|
||||
parentId: node.parentId,
|
||||
width: node.width,
|
||||
height: node.height,
|
||||
frameThickness: node.frameThickness,
|
||||
frameDepth: node.frameDepth,
|
||||
threshold: node.threshold,
|
||||
thresholdHeight: node.thresholdHeight,
|
||||
hingesSide: node.hingesSide,
|
||||
swingDirection: node.swingDirection,
|
||||
segments: node.segments.map(s => ({ ...s, columnRatios: [...s.columnRatios] })),
|
||||
handle: node.handle,
|
||||
handleHeight: node.handleHeight,
|
||||
handleSide: node.handleSide,
|
||||
doorCloser: node.doorCloser,
|
||||
panicBar: node.panicBar,
|
||||
panicBarHeight: node.panicBarHeight,
|
||||
metadata: { isNew: true },
|
||||
})
|
||||
const cloned = structuredClone(node) as any
|
||||
delete cloned.id
|
||||
cloned.metadata = { ...cloned.metadata, isNew: true }
|
||||
const duplicate = DoorNode.parse(cloned)
|
||||
useScene.getState().createNode(duplicate, node.parentId as AnyNodeId)
|
||||
setMovingNode(duplicate)
|
||||
setSelection({ selectedIds: [] })
|
||||
|
||||
@@ -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,8 +1,9 @@
|
||||
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' ? (
|
||||
<PerspectiveCamera far={1000} fov={50} makeDefault near={0.1} position={[10, 10, 10]} />
|
||||
) : (
|
||||
|
||||
@@ -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'
|
||||
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 { 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<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 = () => {
|
||||
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
|
||||
|
||||
|
||||
@@ -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