Merge branch 'main' into feat/elevator-system
# Conflicts: # packages/editor/src/components/tools/item/move-tool.tsx # packages/editor/src/components/tools/tool-manager.tsx # packages/editor/src/components/ui/panels/panel-manager.tsx # packages/editor/src/store/use-editor.tsx # packages/viewer/src/components/renderers/site/site-renderer.tsx # packages/viewer/src/components/viewer/ground-occluder.tsx # packages/viewer/src/components/viewer/index.tsx # packages/viewer/src/components/viewer/post-processing.tsx
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@pascal-app/viewer",
|
||||
"version": "0.6.0",
|
||||
"version": "0.8.0",
|
||||
"description": "3D viewer component for Pascal building editor",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
@@ -22,7 +22,7 @@
|
||||
"prepublishOnly": "npm run build"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@pascal-app/core": "^0.6.0",
|
||||
"@pascal-app/core": "^0.8.0",
|
||||
"@react-three/drei": "^10",
|
||||
"@react-three/fiber": "^9",
|
||||
"react": "^18 || ^19",
|
||||
@@ -35,10 +35,10 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@pascal/typescript-config": "*",
|
||||
"@types/node": "^25.5.0",
|
||||
"@types/node": "^22",
|
||||
"@types/react": "^19.2.2",
|
||||
"@types/three": "^0.184.0",
|
||||
"typescript": "5.9.3"
|
||||
"typescript": "6.0.2"
|
||||
},
|
||||
"keywords": [
|
||||
"3d",
|
||||
|
||||
@@ -4,12 +4,19 @@ import {
|
||||
resolveMaterial,
|
||||
useRegistry,
|
||||
} from '@pascal-app/core'
|
||||
import { useMemo, useRef } from 'react'
|
||||
import { useEffect, useMemo, useRef } from 'react'
|
||||
import { BufferGeometry, Float32BufferAttribute } from 'three'
|
||||
import { float, mix, positionWorld, smoothstep } from 'three/tsl'
|
||||
import { BackSide, FrontSide, type Mesh, MeshBasicNodeMaterial } from 'three/webgpu'
|
||||
import { useNodeEvents } from '../../../hooks/use-node-events'
|
||||
import { NodeRenderer } from '../node-renderer'
|
||||
|
||||
function createEmptyGeometry() {
|
||||
const geometry = new BufferGeometry()
|
||||
geometry.setAttribute('position', new Float32BufferAttribute([], 3))
|
||||
return geometry
|
||||
}
|
||||
|
||||
const gridScale = 5
|
||||
const gridX = positionWorld.x.mul(gridScale).fract()
|
||||
const gridY = positionWorld.z.mul(gridScale).fract()
|
||||
@@ -51,10 +58,20 @@ function getCeilingMaterials(color = '#999999') {
|
||||
|
||||
export const CeilingRenderer = ({ node }: { node: CeilingNode }) => {
|
||||
const ref = useRef<Mesh>(null!)
|
||||
const placeholderGeometry = useMemo(createEmptyGeometry, [])
|
||||
const gridPlaceholderGeometry = useMemo(createEmptyGeometry, [])
|
||||
|
||||
useRegistry(node.id, 'ceiling', ref)
|
||||
const handlers = useNodeEvents(node, 'ceiling')
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
placeholderGeometry.dispose()
|
||||
gridPlaceholderGeometry.dispose()
|
||||
},
|
||||
[gridPlaceholderGeometry, placeholderGeometry],
|
||||
)
|
||||
|
||||
const materials = useMemo(() => {
|
||||
const preset = getMaterialPresetByRef(node.materialPreset)
|
||||
const props = preset?.mapProperties ?? resolveMaterial(node.material)
|
||||
@@ -69,17 +86,15 @@ export const CeilingRenderer = ({ node }: { node: CeilingNode }) => {
|
||||
])
|
||||
|
||||
return (
|
||||
<mesh material={materials.bottomMaterial} ref={ref}>
|
||||
<boxGeometry args={[0, 0, 0]} />
|
||||
<mesh geometry={placeholderGeometry} material={materials.bottomMaterial} ref={ref}>
|
||||
<mesh
|
||||
geometry={gridPlaceholderGeometry}
|
||||
material={materials.topMaterial}
|
||||
name="ceiling-grid"
|
||||
{...handlers}
|
||||
scale={0}
|
||||
visible={false}
|
||||
>
|
||||
<boxGeometry args={[0, 0, 0]} />
|
||||
</mesh>
|
||||
/>
|
||||
{node.children.map((childId) => (
|
||||
<NodeRenderer key={childId} nodeId={childId} />
|
||||
))}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
import { type DoorNode, useRegistry, useScene } from '@pascal-app/core'
|
||||
import { useLayoutEffect, useRef } from 'react'
|
||||
import { MeshBasicMaterial, type Mesh } from 'three'
|
||||
import { type Mesh, MeshBasicMaterial } from 'three'
|
||||
import { useNodeEvents } from '../../../hooks/use-node-events'
|
||||
|
||||
const doorHitboxMaterial = new MeshBasicMaterial({ visible: false })
|
||||
|
||||
@@ -2,28 +2,12 @@ import { type FenceNode, useRegistry, useScene } from '@pascal-app/core'
|
||||
import { useLayoutEffect, useMemo, useRef } from 'react'
|
||||
import type { Mesh } from 'three'
|
||||
import { useNodeEvents } from '../../../hooks/use-node-events'
|
||||
import {
|
||||
createMaterial,
|
||||
createMaterialFromPresetRef,
|
||||
DEFAULT_STAIR_MATERIAL,
|
||||
} from '../../../lib/materials'
|
||||
import { DEFAULT_STAIR_MATERIAL } from '../../../lib/materials'
|
||||
|
||||
export const FenceRenderer = ({ node }: { node: FenceNode }) => {
|
||||
const ref = useRef<Mesh>(null!)
|
||||
const handlers = useNodeEvents(node, 'fence')
|
||||
const material = useMemo(() => {
|
||||
const presetMaterial = createMaterialFromPresetRef(node.materialPreset)
|
||||
if (presetMaterial) return presetMaterial
|
||||
const mat = node.material
|
||||
if (!mat) return DEFAULT_STAIR_MATERIAL
|
||||
return createMaterial(mat)
|
||||
}, [
|
||||
node.materialPreset,
|
||||
node.material,
|
||||
node.material?.preset,
|
||||
node.material?.properties,
|
||||
node.material?.texture,
|
||||
])
|
||||
const material = useMemo(() => DEFAULT_STAIR_MATERIAL, [])
|
||||
|
||||
useRegistry(node.id, 'fence', ref)
|
||||
useLayoutEffect(() => {
|
||||
|
||||
@@ -156,9 +156,12 @@ const ModelRenderer = ({ node }: { node: ItemNode }) => {
|
||||
const lightEffects =
|
||||
interactive?.effects.filter((e): e is LightEffect => e.kind === 'light') ?? []
|
||||
|
||||
// useGLTF caches scenes, and Clone shares child geometry/material references.
|
||||
// Undo can unmount one item while another clone of the same asset still needs them.
|
||||
return (
|
||||
<>
|
||||
<Clone
|
||||
dispose={null}
|
||||
object={scene}
|
||||
position={node.asset.offset}
|
||||
ref={ref}
|
||||
|
||||
@@ -90,6 +90,7 @@ export const SiteRenderer = ({ node }: { node: SiteNode }) => {
|
||||
|
||||
if (slabPolygons.length > 0) {
|
||||
for (const ring of unionPolygons(slabPolygons.map((p) => p.map((pt) => [pt[0], -pt[1]])))) {
|
||||
if (ring.length < 3) continue
|
||||
const hole = new Path()
|
||||
hole.moveTo(ring[0]![0], ring[0]![1])
|
||||
for (let i = 1; i < ring.length; i++) hole.lineTo(ring[i]![0], ring[i]![1])
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { getMaterialPresetByRef, type SlabNode, useRegistry } from '@pascal-app/core'
|
||||
import { useMemo, useRef } from 'react'
|
||||
import { useEffect, useMemo, useRef } from 'react'
|
||||
import type { Mesh } from 'three'
|
||||
import * as THREE from 'three'
|
||||
import { useNodeEvents } from '../../../hooks/use-node-events'
|
||||
@@ -11,6 +11,12 @@ import {
|
||||
|
||||
const slabMaterialCache = new Map<string, THREE.MeshStandardMaterial>()
|
||||
|
||||
function createEmptyGeometry() {
|
||||
const geometry = new THREE.BufferGeometry()
|
||||
geometry.setAttribute('position', new THREE.Float32BufferAttribute([], 3))
|
||||
return geometry
|
||||
}
|
||||
|
||||
function getSlabMaterial(
|
||||
cacheKey: string,
|
||||
params: { material?: SlabNode['material']; materialPreset?: string },
|
||||
@@ -47,11 +53,14 @@ function getSlabMaterial(
|
||||
|
||||
export const SlabRenderer = ({ node }: { node: SlabNode }) => {
|
||||
const ref = useRef<Mesh>(null!)
|
||||
const placeholderGeometry = useMemo(createEmptyGeometry, [])
|
||||
|
||||
useRegistry(node.id, 'slab', ref)
|
||||
|
||||
const handlers = useNodeEvents(node, 'slab')
|
||||
|
||||
useEffect(() => () => placeholderGeometry.dispose(), [placeholderGeometry])
|
||||
|
||||
const material = useMemo(() => {
|
||||
const resolvedMaterial = node.material
|
||||
const resolvedMaterialPreset = node.materialPreset
|
||||
@@ -75,13 +84,12 @@ export const SlabRenderer = ({ node }: { node: SlabNode }) => {
|
||||
return (
|
||||
<mesh
|
||||
castShadow
|
||||
geometry={placeholderGeometry}
|
||||
receiveShadow
|
||||
ref={ref}
|
||||
{...handlers}
|
||||
material={material}
|
||||
visible={node.visible}
|
||||
>
|
||||
<boxGeometry args={[0, 0, 0]} />
|
||||
</mesh>
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -24,10 +24,27 @@ export const StairSegmentRenderer = ({ node }: { node: StairSegmentNode }) => {
|
||||
const parentNode = node.parentId
|
||||
? (nodes[node.parentId as AnyNodeId] as StairNode | undefined)
|
||||
: undefined
|
||||
const material = useMemo(
|
||||
() => getStraightStairSegmentBodyMaterials(node, parentNode),
|
||||
[node, parentNode],
|
||||
)
|
||||
|
||||
const material = useMemo(() => {
|
||||
return getStraightStairSegmentBodyMaterials(node, parentNode)
|
||||
}, [
|
||||
node.materialPreset,
|
||||
node.material,
|
||||
node.material?.preset,
|
||||
node.material?.properties,
|
||||
node.material?.texture,
|
||||
parentNode?.materialPreset,
|
||||
parentNode?.material,
|
||||
parentNode?.material?.preset,
|
||||
parentNode?.material?.properties,
|
||||
parentNode?.material?.texture,
|
||||
parentNode?.railingMaterialPreset,
|
||||
parentNode?.railingMaterial,
|
||||
parentNode?.sideMaterialPreset,
|
||||
parentNode?.sideMaterial,
|
||||
parentNode?.treadMaterialPreset,
|
||||
parentNode?.treadMaterial,
|
||||
])
|
||||
|
||||
const placeholderGeometry = useMemo(() => {
|
||||
const geometry = new THREE.BufferGeometry()
|
||||
|
||||
@@ -112,9 +112,9 @@ export const StairRenderer = ({ node }: { node: StairNode }) => {
|
||||
receiveShadow
|
||||
/>
|
||||
) : null}
|
||||
{!isSegmentBasedStair ? (
|
||||
{isSegmentBasedStair ? null : (
|
||||
<CurvedStairBody bodyMaterials={straightBodyMaterials} stair={node} />
|
||||
) : null}
|
||||
)}
|
||||
<StairRailings material={railingMaterial} stair={node} />
|
||||
{isSegmentBasedStair ? (
|
||||
<group name="segments-wrapper" visible={false}>
|
||||
@@ -292,7 +292,7 @@ function StairRailings({ stair, material }: { stair: StairNode; material: THREE.
|
||||
))}
|
||||
{railPaths.slice(1).map((segmentPath, index) => {
|
||||
const previousPath = railPaths[index]
|
||||
if (!previousPath || !segmentPath.connectFromPrevious) return null
|
||||
if (!(previousPath && segmentPath.connectFromPrevious)) return null
|
||||
if (previousPath.layout.segment.segmentType === 'landing') return null
|
||||
if (segmentPath.layout.segment.segmentType === 'landing') return null
|
||||
|
||||
@@ -451,10 +451,10 @@ function CurvedStairBody({
|
||||
{isSpiral && (stair.showCenterColumn ?? true) ? (
|
||||
<mesh
|
||||
castShadow
|
||||
receiveShadow
|
||||
material={sideMaterial}
|
||||
name="stair-side"
|
||||
position={[0, spiralColumnHeight / 2, 0]}
|
||||
receiveShadow
|
||||
>
|
||||
<cylinderGeometry
|
||||
args={[spiralColumnRadius, spiralColumnRadius, spiralColumnHeight, 10]}
|
||||
|
||||
@@ -1,12 +1,27 @@
|
||||
import { useRegistry, useScene, type WallNode } from '@pascal-app/core'
|
||||
import { useLayoutEffect, useRef } from 'react'
|
||||
import type { Mesh } from 'three'
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef } from 'react'
|
||||
import { BufferGeometry, Float32BufferAttribute, type Mesh } from 'three'
|
||||
import { useNodeEvents } from '../../../hooks/use-node-events'
|
||||
import { getVisibleWallMaterials } from '../../../systems/wall/wall-materials'
|
||||
import { NodeRenderer } from '../node-renderer'
|
||||
|
||||
function createEmptyWallGeometry() {
|
||||
const geometry = new BufferGeometry()
|
||||
geometry.setAttribute('position', new Float32BufferAttribute([], 3))
|
||||
geometry.addGroup(0, 0, 0)
|
||||
geometry.addGroup(0, 0, 1)
|
||||
geometry.addGroup(0, 0, 2)
|
||||
return geometry
|
||||
}
|
||||
|
||||
export const WallRenderer = ({ node }: { node: WallNode }) => {
|
||||
const ref = useRef<Mesh>(null!)
|
||||
const placeholderGeometry = useMemo(createEmptyWallGeometry, [])
|
||||
const collisionPlaceholderGeometry = useMemo(() => {
|
||||
const geometry = new BufferGeometry()
|
||||
geometry.setAttribute('position', new Float32BufferAttribute([], 3))
|
||||
return geometry
|
||||
}, [])
|
||||
|
||||
useRegistry(node.id, 'wall', ref)
|
||||
|
||||
@@ -14,15 +29,31 @@ export const WallRenderer = ({ node }: { node: WallNode }) => {
|
||||
useScene.getState().markDirty(node.id)
|
||||
}, [node.id])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
placeholderGeometry.dispose()
|
||||
collisionPlaceholderGeometry.dispose()
|
||||
}
|
||||
}, [collisionPlaceholderGeometry, placeholderGeometry])
|
||||
|
||||
const handlers = useNodeEvents(node, 'wall')
|
||||
const material = getVisibleWallMaterials(node)
|
||||
|
||||
return (
|
||||
<mesh castShadow material={material} receiveShadow ref={ref} visible={node.visible}>
|
||||
<boxGeometry args={[0, 0, 0]} />
|
||||
<mesh name="collision-mesh" visible={false} {...handlers}>
|
||||
<boxGeometry args={[0, 0, 0]} />
|
||||
</mesh>
|
||||
<mesh
|
||||
castShadow
|
||||
geometry={placeholderGeometry}
|
||||
material={material}
|
||||
receiveShadow
|
||||
ref={ref}
|
||||
visible={node.visible}
|
||||
>
|
||||
<mesh
|
||||
geometry={collisionPlaceholderGeometry}
|
||||
name="collision-mesh"
|
||||
visible={false}
|
||||
{...handlers}
|
||||
/>
|
||||
|
||||
{node.children.map((childId) => (
|
||||
<NodeRenderer key={`${node.id}:${childId}`} nodeId={childId} />
|
||||
|
||||
@@ -6,7 +6,8 @@ type FrameLimiterProps = {
|
||||
}
|
||||
|
||||
const FrameLimiter: React.FC<FrameLimiterProps> = ({ fps = 50 }) => {
|
||||
const { advance, set, frameloop: initFrameloop } = useThree()
|
||||
const { advance, set, frameloop: initFrameloop, scene, clock } = useThree()
|
||||
const renderer = useThree((state) => state.gl)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
let elapsed = 0
|
||||
@@ -14,7 +15,6 @@ const FrameLimiter: React.FC<FrameLimiterProps> = ({ fps = 50 }) => {
|
||||
let i = 0
|
||||
let raf: number | null = null
|
||||
const interval = 1000 / fps
|
||||
|
||||
function tick(t: DOMHighResTimeStamp) {
|
||||
raf = requestAnimationFrame(tick)
|
||||
elapsed = t - then
|
||||
@@ -24,10 +24,11 @@ const FrameLimiter: React.FC<FrameLimiterProps> = ({ fps = 50 }) => {
|
||||
then = t - (elapsed % interval)
|
||||
}
|
||||
}
|
||||
|
||||
// Set frameloop to never, it will shut down the default render loop
|
||||
set({ frameloop: 'never' })
|
||||
// Kick off custom render loop
|
||||
raf = requestAnimationFrame(tick)
|
||||
|
||||
// Restore initial setting
|
||||
return () => {
|
||||
if (raf) {
|
||||
cancelAnimationFrame(raf)
|
||||
|
||||
@@ -65,6 +65,7 @@ export const GroundOccluder = () => {
|
||||
|
||||
if (polygons.length > 0) {
|
||||
for (const ring of unionPolygons(polygons.map((pts) => pts.map((p) => [p[0], -p[1]])))) {
|
||||
if (ring.length < 3) continue
|
||||
const hole = new THREE.Path()
|
||||
|
||||
hole.moveTo(ring[0]![0], ring[0]![1])
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { ElevatorOpeningSystem } from '@pascal-app/core'
|
||||
import { Bvh } from '@react-three/drei'
|
||||
import { Canvas, extend, type ThreeToJSXElements, useFrame, useThree } from '@react-three/fiber'
|
||||
import { useEffect, useMemo, useRef } from 'react'
|
||||
import * as THREE from 'three/webgpu'
|
||||
@@ -31,6 +30,7 @@ import FrameLimiter from './frame-limiter'
|
||||
import { Lights } from './lights'
|
||||
import { PerfMonitor } from './perf-monitor'
|
||||
import PostProcessing, { DEFAULT_HOVER_STYLES, type HoverStyles } from './post-processing'
|
||||
import { SceneBvh } from './scene-bvh'
|
||||
import { SelectionManager } from './selection-manager'
|
||||
import { ViewerCamera } from './viewer-camera'
|
||||
|
||||
@@ -84,7 +84,7 @@ extend(THREE as any)
|
||||
const WEBGPU_RENDERER_CACHE = new WeakMap<HTMLCanvasElement, Promise<THREE.WebGPURenderer>>()
|
||||
|
||||
/**
|
||||
* Monitors the WebGPU device for loss events and logs them.
|
||||
* Monitors the WebGPU device for loss / uncaptured errors and logs them.
|
||||
* WebGPU device loss can happen when:
|
||||
* - Tab is backgrounded and OS reclaims GPU
|
||||
* - Driver crash or GPU reset
|
||||
@@ -97,6 +97,8 @@ type WebGPUDeviceLossInfo = {
|
||||
|
||||
type WebGPUDeviceLike = {
|
||||
lost: Promise<WebGPUDeviceLossInfo>
|
||||
label?: string
|
||||
features?: Set<string>
|
||||
addEventListener?: (type: string, listener: EventListener) => void
|
||||
removeEventListener?: (type: string, listener: EventListener) => void
|
||||
}
|
||||
@@ -109,9 +111,18 @@ function GPUDeviceWatcher() {
|
||||
const device = backend?.device as WebGPUDeviceLike | undefined
|
||||
|
||||
if (!device) {
|
||||
console.warn('[viewer] No WebGPU device on backend — running on a fallback renderer.', {
|
||||
backend: backend?.constructor?.name ?? 'unknown',
|
||||
rendererType: (gl as any).constructor?.name ?? 'unknown',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
console.log('[viewer] WebGPU device ready', {
|
||||
label: device.label,
|
||||
features: Array.from(device.features ?? []),
|
||||
})
|
||||
|
||||
device.lost.then((info: WebGPUDeviceLossInfo) => {
|
||||
console.error(
|
||||
`[viewer] WebGPU device lost: reason="${info.reason ?? 'unknown'}", message="${info.message ?? ''}". ` +
|
||||
@@ -139,6 +150,7 @@ interface ViewerProps {
|
||||
hoverStyles?: HoverStyles
|
||||
selectionManager?: 'default' | 'custom'
|
||||
perf?: boolean
|
||||
useBvh?: boolean
|
||||
}
|
||||
|
||||
const Viewer: React.FC<ViewerProps> = ({
|
||||
@@ -146,6 +158,7 @@ const Viewer: React.FC<ViewerProps> = ({
|
||||
hoverStyles = DEFAULT_HOVER_STYLES,
|
||||
selectionManager = 'default',
|
||||
perf = false,
|
||||
useBvh = true,
|
||||
}) => {
|
||||
const theme = useViewer((state) => state.theme)
|
||||
return (
|
||||
@@ -196,9 +209,13 @@ const Viewer: React.FC<ViewerProps> = ({
|
||||
{/* <directionalLight position={[10, 10, 5]} intensity={0.5} castShadow
|
||||
/> */}
|
||||
<Lights />
|
||||
<Bvh>
|
||||
{useBvh ? (
|
||||
<SceneBvh>
|
||||
<SceneRenderer />
|
||||
</SceneBvh>
|
||||
) : (
|
||||
<SceneRenderer />
|
||||
</Bvh>
|
||||
)}
|
||||
|
||||
{/* Default Systems */}
|
||||
<LevelSystem />
|
||||
|
||||
@@ -131,7 +131,6 @@ const PostProcessingPasses = ({
|
||||
|
||||
// Reset retry state when project changes
|
||||
useEffect(() => {
|
||||
// Intentionally touch projectId so the effect reruns on project switches.
|
||||
void projectId
|
||||
retryCountRef.current = 0
|
||||
if (rebuildTimeoutRef.current !== null) {
|
||||
@@ -168,15 +167,23 @@ const PostProcessingPasses = ({
|
||||
|
||||
// Build / rebuild the post-processing pipeline
|
||||
useEffect(() => {
|
||||
// Intentionally touch these so React/biome treat project switches and retry bumps
|
||||
// as explicit rebuild triggers instead of accidental extra dependencies.
|
||||
void projectId
|
||||
void pipelineVersion
|
||||
|
||||
if (!(renderer && scene && camera)) {
|
||||
console.warn('[viewer/post-processing] Skipping pipeline build — missing dependency.', {
|
||||
hasRenderer: !!renderer,
|
||||
hasScene: !!scene,
|
||||
hasCamera: !!camera,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
console.log('[viewer/post-processing] Building pipeline', {
|
||||
version: pipelineVersion,
|
||||
ssgi: SSGI_PARAMS.enabled,
|
||||
hoverHighlightMode,
|
||||
projectId,
|
||||
rendererCtor: (renderer as any).constructor?.name,
|
||||
})
|
||||
|
||||
hasPipelineErrorRef.current = false
|
||||
|
||||
// WebGPU availability check: SSGI, denoise, and RenderPipeline are all
|
||||
@@ -341,6 +348,7 @@ const PostProcessingPasses = ({
|
||||
}, [
|
||||
camera,
|
||||
hoverHiddenColor,
|
||||
hoverHighlightMode,
|
||||
hoverPulseMix,
|
||||
hoverStrength,
|
||||
hoverVisibleColor,
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import { useThree } from '@react-three/fiber'
|
||||
import {
|
||||
type ReactNode,
|
||||
forwardRef,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useRef,
|
||||
} from 'react'
|
||||
import { Group, Mesh, type BufferGeometry } from 'three'
|
||||
import {
|
||||
SAH,
|
||||
acceleratedRaycast,
|
||||
computeBoundsTree,
|
||||
disposeBoundsTree,
|
||||
type SplitStrategy,
|
||||
} from 'three-mesh-bvh'
|
||||
|
||||
type SceneBvhProps = {
|
||||
children?: ReactNode
|
||||
enabled?: boolean
|
||||
firstHitOnly?: boolean
|
||||
strategy?: SplitStrategy
|
||||
verbose?: boolean
|
||||
setBoundingBox?: boolean
|
||||
maxDepth?: number
|
||||
maxLeafSize?: number
|
||||
indirect?: boolean
|
||||
}
|
||||
|
||||
const isMesh = (object: unknown): object is Mesh =>
|
||||
!!object && typeof object === 'object' && (object as Mesh).isMesh === true
|
||||
|
||||
const hasBvhCompatibleGeometry = (geometry?: BufferGeometry | null) => {
|
||||
if (!geometry) return false
|
||||
|
||||
const position = geometry.getAttribute('position')
|
||||
if (!position) return false
|
||||
|
||||
const vertexCount = geometry.getIndex()?.count ?? position.count
|
||||
return vertexCount >= 3
|
||||
}
|
||||
|
||||
export const SceneBvh = forwardRef<Group, SceneBvhProps>(
|
||||
(
|
||||
{
|
||||
children,
|
||||
enabled = true,
|
||||
firstHitOnly = false,
|
||||
strategy = SAH,
|
||||
verbose = false,
|
||||
setBoundingBox = true,
|
||||
maxDepth = 40,
|
||||
maxLeafSize = 10,
|
||||
indirect = false,
|
||||
},
|
||||
forwardedRef,
|
||||
) => {
|
||||
const ref = useRef<Group>(null)
|
||||
const raycaster = useThree((state) => state.raycaster)
|
||||
|
||||
useImperativeHandle(forwardedRef, () => ref.current!, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !ref.current) return
|
||||
|
||||
const options = {
|
||||
strategy,
|
||||
verbose,
|
||||
setBoundingBox,
|
||||
maxDepth,
|
||||
maxLeafSize,
|
||||
indirect,
|
||||
}
|
||||
const group = ref.current
|
||||
const acceleratedMeshes = new Set<Mesh>()
|
||||
const computedGeometries = new Set<BufferGeometry>()
|
||||
|
||||
;(raycaster as any).firstHitOnly = firstHitOnly
|
||||
|
||||
group.traverse((child) => {
|
||||
if (!isMesh(child)) return
|
||||
|
||||
if (child.raycast === Mesh.prototype.raycast) {
|
||||
child.raycast = acceleratedRaycast
|
||||
acceleratedMeshes.add(child)
|
||||
}
|
||||
|
||||
if (child.raycast !== acceleratedRaycast) return
|
||||
|
||||
const geometry = child.geometry
|
||||
if (geometry.boundsTree || !hasBvhCompatibleGeometry(geometry)) return
|
||||
|
||||
try {
|
||||
geometry.computeBoundsTree = computeBoundsTree
|
||||
geometry.disposeBoundsTree = disposeBoundsTree
|
||||
geometry.computeBoundsTree(options)
|
||||
computedGeometries.add(geometry)
|
||||
} catch (error) {
|
||||
console.warn('[viewer] Skipping BVH for incompatible mesh geometry.', {
|
||||
mesh: child.name || child.type,
|
||||
error,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
delete (raycaster as any).firstHitOnly
|
||||
|
||||
for (const geometry of computedGeometries) {
|
||||
if (geometry.boundsTree) {
|
||||
geometry.disposeBoundsTree()
|
||||
}
|
||||
}
|
||||
|
||||
for (const mesh of acceleratedMeshes) {
|
||||
if (mesh.raycast === acceleratedRaycast) {
|
||||
mesh.raycast = Mesh.prototype.raycast
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [
|
||||
enabled,
|
||||
firstHitOnly,
|
||||
strategy,
|
||||
verbose,
|
||||
setBoundingBox,
|
||||
maxDepth,
|
||||
maxLeafSize,
|
||||
indirect,
|
||||
raycaster,
|
||||
])
|
||||
|
||||
return <group ref={ref}>{children}</group>
|
||||
},
|
||||
)
|
||||
|
||||
SceneBvh.displayName = 'SceneBvh'
|
||||
@@ -20,6 +20,7 @@ export {
|
||||
DEFAULT_WALL_MATERIAL,
|
||||
DEFAULT_WINDOW_MATERIAL,
|
||||
disposeMaterial,
|
||||
glassMaterial,
|
||||
} from './lib/materials'
|
||||
export { mergedOutline } from './lib/merged-outline-node'
|
||||
export { default as useViewer } from './store/use-viewer'
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import {
|
||||
getMaterialPresetByRef,
|
||||
type MaterialMapProperties,
|
||||
type MaterialPresetPayload,
|
||||
type MaterialProperties,
|
||||
type MaterialSchema,
|
||||
getMaterialPresetByRef,
|
||||
resolveMaterial,
|
||||
} from '@pascal-app/core'
|
||||
import * as THREE from 'three'
|
||||
@@ -90,8 +90,7 @@ function getTexture(material?: MaterialSchema): THREE.Texture | undefined {
|
||||
|
||||
function isStandardMaterial(material: THREE.Material): material is StandardMaterial {
|
||||
return (
|
||||
material instanceof THREE.MeshStandardMaterial ||
|
||||
material instanceof THREE.MeshPhysicalMaterial
|
||||
material instanceof THREE.MeshStandardMaterial || material instanceof THREE.MeshPhysicalMaterial
|
||||
)
|
||||
}
|
||||
|
||||
@@ -112,11 +111,19 @@ function applyTextureProperties(
|
||||
return texture
|
||||
}
|
||||
|
||||
function getPresetTextureCacheKey(path: string, props: MaterialMapProperties, slot?: TextureSlot): string {
|
||||
function getPresetTextureCacheKey(
|
||||
path: string,
|
||||
props: MaterialMapProperties,
|
||||
slot?: TextureSlot,
|
||||
): string {
|
||||
return `${path}-${props.repeatX}-${props.repeatY}-${props.rotation}-${props.wrapS}-${props.wrapT}-${props.flipY}-${slot ?? 'map'}`
|
||||
}
|
||||
|
||||
function getPresetTexture(path: string, props: MaterialMapProperties, slot?: TextureSlot): THREE.Texture {
|
||||
function getPresetTexture(
|
||||
path: string,
|
||||
props: MaterialMapProperties,
|
||||
slot?: TextureSlot,
|
||||
): THREE.Texture {
|
||||
const cacheKey = getPresetTextureCacheKey(path, props, slot)
|
||||
const cached = textureCache.get(cacheKey)
|
||||
if (cached) return cached
|
||||
@@ -177,14 +184,17 @@ function queueTextureAssignment(
|
||||
|
||||
material[slot] = null
|
||||
|
||||
void loadPresetTexture(path, props, slot).then((texture) => {
|
||||
loadPresetTexture(path, props, slot).then((texture) => {
|
||||
if (!texture) return
|
||||
material[slot] = texture
|
||||
material.needsUpdate = true
|
||||
})
|
||||
}
|
||||
|
||||
function applyMaterialMapProperties(material: StandardMaterial, mapProperties: MaterialMapProperties) {
|
||||
function applyMaterialMapProperties(
|
||||
material: StandardMaterial,
|
||||
mapProperties: MaterialMapProperties,
|
||||
) {
|
||||
material.color.set(mapProperties.color)
|
||||
material.roughness = mapProperties.roughness
|
||||
material.metalness = mapProperties.metalness
|
||||
@@ -206,10 +216,7 @@ function applyMaterialMapProperties(material: StandardMaterial, mapProperties: M
|
||||
material.needsUpdate = true
|
||||
}
|
||||
|
||||
function applyMaterialPresetTextures(
|
||||
material: StandardMaterial,
|
||||
preset: MaterialPresetPayload,
|
||||
) {
|
||||
function applyMaterialPresetTextures(material: StandardMaterial, preset: MaterialPresetPayload) {
|
||||
const { maps, mapProperties } = preset
|
||||
|
||||
queueTextureAssignment(material, 'map', maps.albedoMap, mapProperties)
|
||||
@@ -243,7 +250,9 @@ export function applyMaterialPresetToMaterials(
|
||||
}
|
||||
}
|
||||
|
||||
export function createMaterialFromPreset(preset: MaterialPresetPayload): THREE.MeshStandardMaterial {
|
||||
export function createMaterialFromPreset(
|
||||
preset: MaterialPresetPayload,
|
||||
): THREE.MeshStandardMaterial {
|
||||
const cacheKey = JSON.stringify(preset)
|
||||
|
||||
if (materialCache.has(cacheKey)) {
|
||||
@@ -256,7 +265,9 @@ export function createMaterialFromPreset(preset: MaterialPresetPayload): THREE.M
|
||||
return material
|
||||
}
|
||||
|
||||
export function createMaterialFromPresetRef(materialPreset?: string): THREE.MeshStandardMaterial | null {
|
||||
export function createMaterialFromPresetRef(
|
||||
materialPreset?: string,
|
||||
): THREE.MeshStandardMaterial | null {
|
||||
const preset = getMaterialPresetByRef(materialPreset)
|
||||
if (!preset) return null
|
||||
return createMaterialFromPreset(preset)
|
||||
@@ -271,16 +282,18 @@ export function createMaterial(material?: MaterialSchema): THREE.MeshStandardMat
|
||||
}
|
||||
|
||||
const map = getTexture(material)
|
||||
|
||||
const threeMaterial = new THREE.MeshStandardMaterial({
|
||||
const materialParams: THREE.MeshStandardMaterialParameters = {
|
||||
color: props.color,
|
||||
roughness: props.roughness,
|
||||
metalness: props.metalness,
|
||||
opacity: props.opacity,
|
||||
transparent: props.transparent,
|
||||
side: sideMap[props.side],
|
||||
map,
|
||||
})
|
||||
}
|
||||
|
||||
if (map) materialParams.map = map
|
||||
|
||||
const threeMaterial = new THREE.MeshStandardMaterial(materialParams)
|
||||
|
||||
materialCache.set(cacheKey, threeMaterial)
|
||||
return threeMaterial
|
||||
|
||||
@@ -622,14 +622,9 @@ export class MergedOutlineNode extends TempNode {
|
||||
|
||||
private _buildCache(objects: Object3D[], cache: Set<Object3D>) {
|
||||
for (const obj of objects) {
|
||||
if (!obj || !obj.traverse) continue
|
||||
try {
|
||||
obj.traverse((child: any) => {
|
||||
if (child.isMesh || child.isSprite) cache.add(child)
|
||||
})
|
||||
} catch {
|
||||
// Skip objects that were disposed or removed from the scene graph
|
||||
}
|
||||
obj.traverse((child: any) => {
|
||||
if (child.isMesh || child.isSprite) cache.add(child)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
/**
|
||||
* Suppresses the `THREE.Clock: This module has been deprecated` warning
|
||||
* emitted by three.js r183+ on every `new THREE.Clock()` call.
|
||||
*
|
||||
* We don't instantiate `Clock` ourselves — `@react-three/fiber` 9.x
|
||||
* (current stable) creates one internally per `<Canvas>` mount. The
|
||||
* migration to `THREE.Timer` lands in R3F 10.x (still alpha).
|
||||
*
|
||||
* Uses three's `setConsoleFunction` hook so we don't touch `console.warn`
|
||||
* globally. Only the exact Clock deprecation message is suppressed; all
|
||||
* other three.js logs (including TSL stack-trace warnings) pass through
|
||||
* untouched.
|
||||
*
|
||||
* Runs in production as well as dev — the suppressed message is a pure
|
||||
* deprecation notice with no user-actionable content.
|
||||
*
|
||||
* REMOVAL: safe to delete once `@react-three/fiber` no longer constructs
|
||||
* `new THREE.Clock()`. To verify after an R3F upgrade:
|
||||
* grep -n "new THREE.Clock" node_modules/@react-three/fiber/dist/*.js
|
||||
* No hits → delete this file and its import in components/viewer/index.tsx.
|
||||
*
|
||||
* @see https://github.com/pascalorg/editor/issues/213
|
||||
*/
|
||||
|
||||
import { setConsoleFunction } from 'three'
|
||||
|
||||
// three's warn() prepends 'THREE.' to its first argument, and the Clock
|
||||
// constructor passes 'THREE.Clock: ...', producing a double-prefixed
|
||||
// string. Exact equality keeps the suppression surgical — if three ever
|
||||
// rewords this, the filter stops matching and the message resurfaces.
|
||||
const CLOCK_DEPRECATION_MESSAGE =
|
||||
'THREE.THREE.Clock: This module has been deprecated. Please use THREE.Timer instead.'
|
||||
|
||||
type ConsoleMethod = 'log' | 'warn' | 'error'
|
||||
|
||||
// HMR-idempotent install guard. setConsoleFunction is a single-slot global
|
||||
// in three, so re-evaluating this module on HMR would reinstall the hook.
|
||||
const INSTALLED = Symbol.for('@pascal-app/viewer/suppress-three-clock-warning')
|
||||
type GlobalWithFlag = typeof globalThis & { [INSTALLED]?: true }
|
||||
|
||||
if (!(globalThis as GlobalWithFlag)[INSTALLED]) {
|
||||
;(globalThis as GlobalWithFlag)[INSTALLED] = true
|
||||
|
||||
setConsoleFunction((method: ConsoleMethod, message: string, ...params: unknown[]) => {
|
||||
if (method === 'warn' && message === CLOCK_DEPRECATION_MESSAGE) {
|
||||
return
|
||||
}
|
||||
|
||||
// Mirror three's default stack-trace handling so TSL warnings/errors
|
||||
// keep their clickable stack frames.
|
||||
if (method !== 'log') {
|
||||
const first = params[0] as
|
||||
| { isStackTrace?: boolean; getError?: (m: string) => Error }
|
||||
| undefined
|
||||
if (first?.isStackTrace && typeof first.getError === 'function') {
|
||||
console[method](first.getError(message))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
console[method](message, ...params)
|
||||
})
|
||||
}
|
||||
+1
-7
@@ -1,10 +1,4 @@
|
||||
import type {
|
||||
AnyNode,
|
||||
BaseNode,
|
||||
BuildingNode,
|
||||
LevelNode,
|
||||
ZoneNode,
|
||||
} from '@pascal-app/core'
|
||||
import type { AnyNode, BaseNode, BuildingNode, LevelNode, ZoneNode } from '@pascal-app/core'
|
||||
import type { Object3D } from 'three'
|
||||
type SelectionPath = {
|
||||
buildingId: BuildingNode['id'] | null
|
||||
|
||||
@@ -51,7 +51,7 @@ function updateCeilingGeometry(node: CeilingNode, mesh: THREE.Mesh) {
|
||||
const gridMesh = mesh.getObjectByName('ceiling-grid') as THREE.Mesh
|
||||
if (gridMesh) {
|
||||
gridMesh.geometry.dispose()
|
||||
gridMesh.geometry = newGeo
|
||||
gridMesh.geometry = newGeo.clone()
|
||||
}
|
||||
|
||||
// Position at the ceiling height
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {
|
||||
clampDoorOperationState,
|
||||
type AnyNodeId,
|
||||
clampDoorOperationState,
|
||||
type DoorNode,
|
||||
getDoorRenderOpenAmount,
|
||||
sceneRegistry,
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useThree } from '@react-three/fiber'
|
||||
import { useEffect } from 'react'
|
||||
import type { Scene } from 'three'
|
||||
import * as THREE from 'three'
|
||||
import { STLExporter } from 'three/examples/jsm/exporters/STLExporter.js'
|
||||
import { GLTFExporter } from 'three/examples/jsm/exporters/GLTFExporter.js'
|
||||
import { OBJExporter } from 'three/examples/jsm/exporters/OBJExporter.js'
|
||||
import useViewer from '../../store/use-viewer'
|
||||
|
||||
const EDITOR_LAYER = 1 // same constant used across the editor
|
||||
|
||||
function downloadBlob(blob: Blob, filename: string) {
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = filename
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
export const ExportSystem = () => {
|
||||
const { scene } = useThree()
|
||||
const setExportScene = useViewer((state) => state.setExportScene)
|
||||
|
||||
useEffect(() => {
|
||||
const exportFn = async (format: 'glb' | 'stl' | 'obj' = 'glb') => {
|
||||
const date = new Date().toISOString().split('T')[0]
|
||||
const filename = `pascal-export-${date}`
|
||||
|
||||
// Clone scene and strip editor-only objects (layer 1 = EDITOR_LAYER)
|
||||
const exportRoot = scene.clone(true) as Scene
|
||||
const toRemove: THREE.Object3D[] = []
|
||||
exportRoot.traverse((obj) => {
|
||||
if (obj.layers.isEnabled(EDITOR_LAYER)) {
|
||||
toRemove.push(obj)
|
||||
}
|
||||
})
|
||||
for (const obj of toRemove) {
|
||||
obj.parent?.remove(obj)
|
||||
}
|
||||
|
||||
if (format === 'glb') {
|
||||
const exporter = new GLTFExporter()
|
||||
const result = await new Promise<ArrayBuffer>((resolve, reject) => {
|
||||
exporter.parse(
|
||||
exportRoot,
|
||||
(output) => resolve(output as ArrayBuffer),
|
||||
(err) => reject(err),
|
||||
{ binary: true }
|
||||
)
|
||||
})
|
||||
downloadBlob(new Blob([result], { type: 'model/gltf-binary' }), `${filename}.glb`)
|
||||
} else if (format === 'stl') {
|
||||
const exporter = new STLExporter()
|
||||
const result = exporter.parse(exportRoot, { binary: true }) as DataView
|
||||
downloadBlob(new Blob([result.buffer as ArrayBuffer], { type: 'model/stl' }), `${filename}.stl`)
|
||||
} else if (format === 'obj') {
|
||||
const exporter = new OBJExporter()
|
||||
const result = exporter.parse(exportRoot)
|
||||
downloadBlob(new Blob([result], { type: 'model/obj' }), `${filename}.obj`)
|
||||
}
|
||||
}
|
||||
|
||||
setExportScene(exportFn)
|
||||
return () => setExportScene(null)
|
||||
}, [scene, setExportScene])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { emitter, sceneRegistry } from '@pascal-app/core'
|
||||
import { sceneRegistry } from '@pascal-app/core'
|
||||
import { useEffect } from 'react'
|
||||
import useViewer from '../../store/use-viewer'
|
||||
|
||||
@@ -15,29 +15,5 @@ export const GuideSystem = () => {
|
||||
})
|
||||
}, [showGuides])
|
||||
|
||||
useEffect(() => {
|
||||
const hideForCapture = () => {
|
||||
const guides = sceneRegistry.byType.guide || new Set()
|
||||
guides.forEach((guideId) => {
|
||||
const node = sceneRegistry.nodes.get(guideId)
|
||||
if (node) node.visible = false
|
||||
})
|
||||
}
|
||||
const restoreAfterCapture = () => {
|
||||
const showGuidesNow = useViewer.getState().showGuides
|
||||
const guides = sceneRegistry.byType.guide || new Set()
|
||||
guides.forEach((guideId) => {
|
||||
const node = sceneRegistry.nodes.get(guideId)
|
||||
if (node) node.visible = showGuidesNow
|
||||
})
|
||||
}
|
||||
emitter.on('thumbnail:before-capture', hideForCapture)
|
||||
emitter.on('thumbnail:after-capture', restoreAfterCapture)
|
||||
return () => {
|
||||
emitter.off('thumbnail:before-capture', hideForCapture)
|
||||
emitter.off('thumbnail:after-capture', restoreAfterCapture)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useFrame } from '@react-three/fiber'
|
||||
import {
|
||||
type AnyNodeId,
|
||||
getScaledDimensions,
|
||||
@@ -9,6 +8,7 @@ import {
|
||||
useScene,
|
||||
type WallNode,
|
||||
} from '@pascal-app/core'
|
||||
import { useFrame } from '@react-three/fiber'
|
||||
import type * as THREE from 'three'
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useFrame } from '@react-three/fiber'
|
||||
import {
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
@@ -8,11 +7,21 @@ import {
|
||||
sceneRegistry,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { useFrame } from '@react-three/fiber'
|
||||
import * as THREE from 'three'
|
||||
import { mergeVertices } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
|
||||
import { ADDITION, Brush, Evaluator, SUBTRACTION } from 'three-bvh-csg'
|
||||
import { computeBoundsTree } from 'three-mesh-bvh'
|
||||
|
||||
function csgGeometry(brush: Brush): THREE.BufferGeometry {
|
||||
return brush.geometry as unknown as THREE.BufferGeometry
|
||||
}
|
||||
|
||||
function csgMaterials(brush: Brush): THREE.Material[] {
|
||||
const mat = (brush as any).material
|
||||
return Array.isArray(mat) ? mat : [mat]
|
||||
}
|
||||
|
||||
const csgEvaluator = new Evaluator()
|
||||
csgEvaluator.useGroups = true
|
||||
;(csgEvaluator as any).consolidateGroups = false // shared dummyMats across brushes causes consolidation to misalign groupIndices vs groupOrder indices → crash
|
||||
@@ -183,7 +192,7 @@ function updateMergedRoofGeometry(
|
||||
)
|
||||
|
||||
const applyTransform = (brush: Brush) => {
|
||||
brush.geometry.applyMatrix4(_matrix)
|
||||
csgGeometry(brush).applyMatrix4(_matrix)
|
||||
brush.updateMatrixWorld()
|
||||
}
|
||||
|
||||
@@ -242,11 +251,9 @@ function updateMergedRoofGeometry(
|
||||
const shinDeck = csgEvaluator.evaluate(finalShinTrimmed, finalDeckTrimmed, ADDITION)
|
||||
const combined = csgEvaluator.evaluate(shinDeck, finalWallTrimmed, ADDITION)
|
||||
|
||||
const resultGeo = combined.geometry
|
||||
const resultGeo = csgGeometry(combined)
|
||||
|
||||
const resultMaterials: THREE.Material[] = Array.isArray(combined.material)
|
||||
? combined.material
|
||||
: [combined.material]
|
||||
const resultMaterials = csgMaterials(combined)
|
||||
|
||||
const matToIndex = new Map<THREE.Material, number>([
|
||||
[dummyMats[0], 0],
|
||||
@@ -259,8 +266,8 @@ function updateMergedRoofGeometry(
|
||||
g.materialIndex = mapRoofGroupMaterialIndex(g.materialIndex, resultMaterials, matToIndex)
|
||||
}
|
||||
|
||||
ensureUv2Attribute(resultGeo)
|
||||
resultGeo.computeVertexNormals()
|
||||
ensureUv2Attribute(resultGeo)
|
||||
mergedMesh.geometry.dispose()
|
||||
mergedMesh.geometry = resultGeo
|
||||
|
||||
@@ -614,11 +621,9 @@ export function generateRoofSegmentGeometry(node: RoofSegmentNode): THREE.Buffer
|
||||
const shinDeck = csgEvaluator.evaluate(shinSlab, deckSlab, ADDITION)
|
||||
const combined = csgEvaluator.evaluate(shinDeck, hollowWall, ADDITION)
|
||||
|
||||
resultGeo = combined.geometry
|
||||
resultGeo = csgGeometry(combined)
|
||||
|
||||
const resultMaterials: THREE.Material[] = Array.isArray(combined.material)
|
||||
? combined.material
|
||||
: [combined.material]
|
||||
const resultMaterials = csgMaterials(combined)
|
||||
|
||||
const matToIndex = new Map<THREE.Material, number>([
|
||||
[dummyMats[0], 0],
|
||||
@@ -641,7 +646,7 @@ export function generateRoofSegmentGeometry(node: RoofSegmentNode): THREE.Buffer
|
||||
shinDeck.geometry.dispose()
|
||||
} catch (e) {
|
||||
console.error('Roof CSG failed:', e)
|
||||
resultGeo = wallBrush.geometry.clone()
|
||||
resultGeo = csgGeometry(wallBrush).clone()
|
||||
}
|
||||
|
||||
deckSlab.geometry.dispose()
|
||||
@@ -649,8 +654,8 @@ export function generateRoofSegmentGeometry(node: RoofSegmentNode): THREE.Buffer
|
||||
wallBrush.geometry.dispose()
|
||||
innerBrush.geometry.dispose()
|
||||
|
||||
ensureUv2Attribute(resultGeo)
|
||||
resultGeo.computeVertexNormals()
|
||||
ensureUv2Attribute(resultGeo)
|
||||
return resultGeo
|
||||
}
|
||||
|
||||
@@ -1014,8 +1019,8 @@ function createGeometryFromFaces(
|
||||
// Merge identical vertices to optimize geometry for CSG and create clean topology
|
||||
const mergedGeo = mergeVertices(geometry, 1e-4)
|
||||
geometry.dispose()
|
||||
ensureUv2Attribute(mergedGeo)
|
||||
|
||||
ensureUv2Attribute(mergedGeo)
|
||||
return mergedGeo
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { emitter, sceneRegistry } from '@pascal-app/core'
|
||||
import { sceneRegistry } from '@pascal-app/core'
|
||||
import { useEffect } from 'react'
|
||||
import useViewer from '../../store/use-viewer'
|
||||
|
||||
@@ -15,29 +15,5 @@ export const ScanSystem = () => {
|
||||
})
|
||||
}, [showScans])
|
||||
|
||||
useEffect(() => {
|
||||
const hideForCapture = () => {
|
||||
const scans = sceneRegistry.byType.scan || new Set()
|
||||
scans.forEach((scanId) => {
|
||||
const node = sceneRegistry.nodes.get(scanId)
|
||||
if (node) node.visible = false
|
||||
})
|
||||
}
|
||||
const restoreAfterCapture = () => {
|
||||
const showScansNow = useViewer.getState().showScans
|
||||
const scans = sceneRegistry.byType.scan || new Set()
|
||||
scans.forEach((scanId) => {
|
||||
const node = sceneRegistry.nodes.get(scanId)
|
||||
if (node) node.visible = showScansNow
|
||||
})
|
||||
}
|
||||
emitter.on('thumbnail:before-capture', hideForCapture)
|
||||
emitter.on('thumbnail:after-capture', restoreAfterCapture)
|
||||
return () => {
|
||||
emitter.off('thumbnail:before-capture', hideForCapture)
|
||||
emitter.off('thumbnail:after-capture', restoreAfterCapture)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { useFrame } from '@react-three/fiber'
|
||||
import {
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
resolveLevelId,
|
||||
sceneRegistry,
|
||||
spatialGridManager,
|
||||
type StairNode,
|
||||
type StairSegmentNode,
|
||||
sceneRegistry,
|
||||
spatialGridManager,
|
||||
syncAutoStairOpenings,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { useFrame } from '@react-three/fiber'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import * as THREE from 'three'
|
||||
import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
|
||||
@@ -459,7 +459,7 @@ function applyStairSegmentUvs(geometry: THREE.BufferGeometry) {
|
||||
const position = geometry.getAttribute('position')
|
||||
const normal = geometry.getAttribute('normal')
|
||||
|
||||
if (!position || !normal || position.count === 0) {
|
||||
if (!(position && normal) || position.count === 0) {
|
||||
geometry.deleteAttribute('uv')
|
||||
return
|
||||
}
|
||||
@@ -609,7 +609,13 @@ function generateStairRailingGeometry(
|
||||
const landingInset = 0.08
|
||||
const geometries: THREE.BufferGeometry[] = []
|
||||
|
||||
const segmentRailPaths = buildStairRailPaths(segments, transforms, railingMode, inset, landingInset)
|
||||
const segmentRailPaths = buildStairRailPaths(
|
||||
segments,
|
||||
transforms,
|
||||
railingMode,
|
||||
inset,
|
||||
landingInset,
|
||||
)
|
||||
|
||||
for (const segmentRailPath of segmentRailPaths) {
|
||||
for (const sidePath of segmentRailPath.sidePaths) {
|
||||
@@ -618,9 +624,7 @@ function generateStairRailingGeometry(
|
||||
|
||||
geometries.push(...buildBalusterGeometries(points, railHeight, postRadius))
|
||||
geometries.push(...buildOffsetRailSegmentGeometries(points, railHeight, railRadius))
|
||||
geometries.push(
|
||||
...buildOffsetRailSegmentGeometries(points, midRailHeight, railRadius * 0.8),
|
||||
)
|
||||
geometries.push(...buildOffsetRailSegmentGeometries(points, midRailHeight, railRadius * 0.8))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -695,42 +699,34 @@ function buildStairRailPaths(
|
||||
previousSegment?.segmentType === 'stair' &&
|
||||
nextSegment?.segmentType === 'stair'
|
||||
const visualTurnSide = nextSegment?.attachmentSide
|
||||
const sideCandidates =
|
||||
hideLandingRailing
|
||||
? visualTurnSide === 'left'
|
||||
? (['front', 'right'] as const)
|
||||
: visualTurnSide === 'right'
|
||||
? (['front', 'left'] as const)
|
||||
: (['left', 'right'] as const)
|
||||
: segment.segmentType === 'landing'
|
||||
? nextSegment?.segmentType === 'landing' && visualTurnSide === 'left'
|
||||
? (['front', 'right'] as const)
|
||||
: nextSegment?.segmentType === 'landing' && visualTurnSide === 'right'
|
||||
? (['front', 'left'] as const)
|
||||
: visualTurnSide === 'left'
|
||||
? (['right'] as const)
|
||||
: visualTurnSide === 'right'
|
||||
? (['left'] as const)
|
||||
: (['left', 'right'] as const)
|
||||
const sideCandidates = hideLandingRailing
|
||||
? visualTurnSide === 'left'
|
||||
? (['front', 'right'] as const)
|
||||
: visualTurnSide === 'right'
|
||||
? (['front', 'left'] as const)
|
||||
: (['left', 'right'] as const)
|
||||
: segment.segmentType === 'landing'
|
||||
? nextSegment?.segmentType === 'landing' && visualTurnSide === 'left'
|
||||
? (['front', 'right'] as const)
|
||||
: nextSegment?.segmentType === 'landing' && visualTurnSide === 'right'
|
||||
? (['front', 'left'] as const)
|
||||
: visualTurnSide === 'left'
|
||||
? (['right'] as const)
|
||||
: visualTurnSide === 'right'
|
||||
? (['left'] as const)
|
||||
: (['left', 'right'] as const)
|
||||
: (['left', 'right'] as const)
|
||||
const sidePaths = sideCandidates
|
||||
.map((side) =>
|
||||
buildSegmentRailPath(
|
||||
layout,
|
||||
side,
|
||||
previousSegment,
|
||||
nextSegment,
|
||||
inset,
|
||||
landingInset,
|
||||
),
|
||||
buildSegmentRailPath(layout, side, previousSegment, nextSegment, inset, landingInset),
|
||||
)
|
||||
.filter((entry): entry is StairRailSidePath => entry !== null)
|
||||
|
||||
return {
|
||||
segment,
|
||||
sidePaths:
|
||||
sidePaths:
|
||||
isStraightLineDoubleLandingLayout && index === 1
|
||||
? ((['left', 'right'] as const)
|
||||
? (['left', 'right'] as const)
|
||||
.map((side) =>
|
||||
buildSegmentRailPath(
|
||||
layout,
|
||||
@@ -741,7 +737,7 @@ function buildStairRailPaths(
|
||||
landingInset,
|
||||
),
|
||||
)
|
||||
.filter((entry): entry is StairRailSidePath => entry !== null))
|
||||
.filter((entry): entry is StairRailSidePath => entry !== null)
|
||||
: sidePaths,
|
||||
connectFromPrevious:
|
||||
index > 0 &&
|
||||
@@ -780,10 +776,20 @@ function buildStairRailPaths(
|
||||
nextAttachmentSide === railingMode
|
||||
: true
|
||||
|
||||
const sidePaths =
|
||||
suppressLandingRailing
|
||||
? []
|
||||
: segment.segmentType !== 'landing'
|
||||
const sidePaths = suppressLandingRailing
|
||||
? []
|
||||
: segment.segmentType !== 'landing'
|
||||
? [
|
||||
buildSegmentRailPath(
|
||||
layout,
|
||||
railingMode,
|
||||
previousSegment,
|
||||
nextSegment,
|
||||
inset,
|
||||
landingInset,
|
||||
),
|
||||
]
|
||||
: isStraightLineDoubleLandingLayout
|
||||
? [
|
||||
buildSegmentRailPath(
|
||||
layout,
|
||||
@@ -794,19 +800,29 @@ function buildStairRailPaths(
|
||||
landingInset,
|
||||
),
|
||||
]
|
||||
: isStraightLineDoubleLandingLayout
|
||||
? [
|
||||
buildSegmentRailPath(
|
||||
layout,
|
||||
railingMode,
|
||||
previousSegment,
|
||||
nextSegment,
|
||||
inset,
|
||||
landingInset,
|
||||
),
|
||||
]
|
||||
: isMiddleLandingBetweenFlights && railingMode === 'left'
|
||||
? nextAttachmentSide === 'right'
|
||||
: isMiddleLandingBetweenFlights && railingMode === 'left'
|
||||
? nextAttachmentSide === 'right'
|
||||
? [
|
||||
buildSegmentRailPath(
|
||||
layout,
|
||||
'front',
|
||||
previousSegment,
|
||||
nextSegment,
|
||||
inset,
|
||||
landingInset,
|
||||
),
|
||||
buildSegmentRailPath(
|
||||
layout,
|
||||
'left',
|
||||
previousSegment,
|
||||
nextSegment,
|
||||
inset,
|
||||
landingInset,
|
||||
),
|
||||
]
|
||||
: []
|
||||
: isMiddleLandingBetweenFlights && railingMode === 'right'
|
||||
? nextAttachmentSide === 'left'
|
||||
? [
|
||||
buildSegmentRailPath(
|
||||
layout,
|
||||
@@ -818,7 +834,7 @@ function buildStairRailPaths(
|
||||
),
|
||||
buildSegmentRailPath(
|
||||
layout,
|
||||
'left',
|
||||
'right',
|
||||
previousSegment,
|
||||
nextSegment,
|
||||
inset,
|
||||
@@ -826,59 +842,38 @@ function buildStairRailPaths(
|
||||
),
|
||||
]
|
||||
: []
|
||||
: isMiddleLandingBetweenFlights && railingMode === 'right'
|
||||
? nextAttachmentSide === 'left'
|
||||
? [
|
||||
buildSegmentRailPath(
|
||||
layout,
|
||||
'front',
|
||||
previousSegment,
|
||||
nextSegment,
|
||||
inset,
|
||||
landingInset,
|
||||
),
|
||||
buildSegmentRailPath(
|
||||
layout,
|
||||
'right',
|
||||
previousSegment,
|
||||
nextSegment,
|
||||
inset,
|
||||
landingInset,
|
||||
),
|
||||
]
|
||||
: []
|
||||
: nextSegment?.segmentType === 'landing' &&
|
||||
nextAttachmentSide != null &&
|
||||
nextAttachmentSide !== 'front' &&
|
||||
nextAttachmentSide !== railingMode
|
||||
? [
|
||||
buildSegmentRailPath(
|
||||
layout,
|
||||
'front',
|
||||
previousSegment,
|
||||
nextSegment,
|
||||
inset,
|
||||
landingInset,
|
||||
),
|
||||
buildSegmentRailPath(
|
||||
layout,
|
||||
railingMode,
|
||||
previousSegment,
|
||||
nextSegment,
|
||||
inset,
|
||||
landingInset,
|
||||
),
|
||||
]
|
||||
: [
|
||||
buildSegmentRailPath(
|
||||
layout,
|
||||
railingMode,
|
||||
previousSegment,
|
||||
nextSegment,
|
||||
inset,
|
||||
landingInset,
|
||||
),
|
||||
]
|
||||
: nextSegment?.segmentType === 'landing' &&
|
||||
nextAttachmentSide != null &&
|
||||
nextAttachmentSide !== 'front' &&
|
||||
nextAttachmentSide !== railingMode
|
||||
? [
|
||||
buildSegmentRailPath(
|
||||
layout,
|
||||
'front',
|
||||
previousSegment,
|
||||
nextSegment,
|
||||
inset,
|
||||
landingInset,
|
||||
),
|
||||
buildSegmentRailPath(
|
||||
layout,
|
||||
railingMode,
|
||||
previousSegment,
|
||||
nextSegment,
|
||||
inset,
|
||||
landingInset,
|
||||
),
|
||||
]
|
||||
: [
|
||||
buildSegmentRailPath(
|
||||
layout,
|
||||
railingMode,
|
||||
previousSegment,
|
||||
nextSegment,
|
||||
inset,
|
||||
landingInset,
|
||||
),
|
||||
]
|
||||
|
||||
resolved.push({
|
||||
segment,
|
||||
@@ -924,10 +919,11 @@ function buildSegmentRailPath(
|
||||
const segmentStepDepth = segment.length / segmentSteps
|
||||
const segmentStepHeight = segment.segmentType === 'landing' ? 0 : segment.height / segmentSteps
|
||||
const segmentTopThickness = getSegmentTopThickness(segment)
|
||||
const flightSideOffset =
|
||||
side === 'left' ? segment.width / 2 - 0.045 : -segment.width / 2 + 0.045
|
||||
const flightSideOffset = side === 'left' ? segment.width / 2 - 0.045 : -segment.width / 2 + 0.045
|
||||
const flightStartX =
|
||||
previousSegment?.segmentType === 'landing' ? -segment.length / 2 + landingInset : -segment.length / 2
|
||||
previousSegment?.segmentType === 'landing'
|
||||
? -segment.length / 2 + landingInset
|
||||
: -segment.length / 2
|
||||
const flightEndX =
|
||||
nextSegment?.segmentType === 'landing' ? segment.length / 2 - landingInset : segment.length / 2
|
||||
|
||||
@@ -947,9 +943,7 @@ function buildSegmentRailPath(
|
||||
points: [
|
||||
...(previousSegment?.segmentType === 'landing'
|
||||
? []
|
||||
: [
|
||||
toRailLayoutWorldPoint(layout, flightStartX, segmentTopThickness, flightSideOffset),
|
||||
]),
|
||||
: [toRailLayoutWorldPoint(layout, flightStartX, segmentTopThickness, flightSideOffset)]),
|
||||
...Array.from({ length: segmentSteps }).map((_, index) =>
|
||||
toRailLayoutWorldPoint(
|
||||
layout,
|
||||
@@ -960,9 +954,7 @@ function buildSegmentRailPath(
|
||||
),
|
||||
...(nextSegment?.segmentType === 'landing'
|
||||
? []
|
||||
: [
|
||||
toRailLayoutWorldPoint(layout, flightEndX, segment.height, flightSideOffset),
|
||||
]),
|
||||
: [toRailLayoutWorldPoint(layout, flightEndX, segment.height, flightSideOffset)]),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +37,10 @@ function computeGeometryBoundsTree(geometry: THREE.BufferGeometry) {
|
||||
;(geometry as any).computeBoundsTree({ maxLeafSize: 10 })
|
||||
}
|
||||
|
||||
function csgGeometry(brush: Brush): THREE.BufferGeometry {
|
||||
return brush.geometry as unknown as THREE.BufferGeometry
|
||||
}
|
||||
|
||||
type WallBoundaryEdgeTag = 'front' | 'back' | 'base'
|
||||
|
||||
type TaggedWallBoundaryEdge = {
|
||||
@@ -57,7 +61,7 @@ function insetCurvedWallBoundaryPointsFor3D(
|
||||
boundaryPoints: ReturnType<typeof getWallMiterBoundaryPoints>,
|
||||
miterData: WallMiterData,
|
||||
) {
|
||||
if (!boundaryPoints || !isCurvedWall(wall)) {
|
||||
if (!(boundaryPoints && isCurvedWall(wall))) {
|
||||
return boundaryPoints
|
||||
}
|
||||
|
||||
@@ -431,7 +435,7 @@ export function generateExtrudedWall(
|
||||
childrenNodes: AnyNode[],
|
||||
miterData: WallMiterData,
|
||||
slabElevation = 0,
|
||||
) {
|
||||
): THREE.BufferGeometry {
|
||||
const wallStart: Point2D = { x: wallNode.start[0], y: wallNode.start[1] }
|
||||
const wallEnd: Point2D = { x: wallNode.end[0], y: wallNode.end[1] }
|
||||
// Positive slab: shift the whole wall up (full height preserved)
|
||||
@@ -519,18 +523,18 @@ export function generateExtrudedWall(
|
||||
cutoutBrush.updateMatrixWorld()
|
||||
const newResult = csgEvaluator.evaluate(resultBrush, cutoutBrush, SUBTRACTION)
|
||||
if (resultBrush !== wallBrush) {
|
||||
resultBrush.geometry.dispose()
|
||||
csgGeometry(resultBrush).dispose()
|
||||
}
|
||||
resultBrush = newResult
|
||||
}
|
||||
|
||||
// Clean up
|
||||
wallBrush.geometry.dispose()
|
||||
csgGeometry(wallBrush).dispose()
|
||||
for (const brush of cutoutBrushes) {
|
||||
brush.geometry.dispose()
|
||||
csgGeometry(brush).dispose()
|
||||
}
|
||||
|
||||
const resultGeometry = resultBrush.geometry
|
||||
const resultGeometry = csgGeometry(resultBrush)
|
||||
resultGeometry.computeVertexNormals()
|
||||
assignWallMaterialGroups(resultGeometry, wallNode, boundaryEdges)
|
||||
ensureUv2Attribute(resultGeometry)
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
"rootDir": "src",
|
||||
"noEmit": false,
|
||||
"composite": true,
|
||||
"incremental": true
|
||||
"incremental": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "dist"],
|
||||
|
||||
Reference in New Issue
Block a user