Merge branch 'main' into feat/2d-editor
This commit is contained in:
@@ -1,4 +1,9 @@
|
||||
import { type CeilingNode, getMaterialPresetByRef, resolveMaterial, useRegistry } from '@pascal-app/core'
|
||||
import {
|
||||
type CeilingNode,
|
||||
getMaterialPresetByRef,
|
||||
resolveMaterial,
|
||||
useRegistry,
|
||||
} from '@pascal-app/core'
|
||||
import { useMemo, useRef } from 'react'
|
||||
import { float, mix, positionWorld, smoothstep } from 'three/tsl'
|
||||
import { BackSide, FrontSide, type Mesh, MeshBasicNodeMaterial } from 'three/webgpu'
|
||||
@@ -32,6 +37,18 @@ function createCeilingMaterials(color = '#999999') {
|
||||
return { topMaterial, bottomMaterial }
|
||||
}
|
||||
|
||||
const ceilingMaterialCache = new Map<string, ReturnType<typeof createCeilingMaterials>>()
|
||||
|
||||
function getCeilingMaterials(color = '#999999') {
|
||||
const cacheKey = color
|
||||
const cached = ceilingMaterialCache.get(cacheKey)
|
||||
if (cached) return cached
|
||||
|
||||
const materials = createCeilingMaterials(color)
|
||||
ceilingMaterialCache.set(cacheKey, materials)
|
||||
return materials
|
||||
}
|
||||
|
||||
export const CeilingRenderer = ({ node }: { node: CeilingNode }) => {
|
||||
const ref = useRef<Mesh>(null!)
|
||||
|
||||
@@ -42,8 +59,14 @@ export const CeilingRenderer = ({ node }: { node: CeilingNode }) => {
|
||||
const preset = getMaterialPresetByRef(node.materialPreset)
|
||||
const props = preset?.mapProperties ?? resolveMaterial(node.material)
|
||||
const color = props.color || '#999999'
|
||||
return createCeilingMaterials(color)
|
||||
}, [node.materialPreset, node.material, node.material?.preset, node.material?.properties, node.material?.texture])
|
||||
return getCeilingMaterials(color)
|
||||
}, [
|
||||
node.materialPreset,
|
||||
node.material,
|
||||
node.material?.preset,
|
||||
node.material?.properties,
|
||||
node.material?.texture,
|
||||
])
|
||||
|
||||
return (
|
||||
<mesh material={materials.bottomMaterial} ref={ref}>
|
||||
|
||||
@@ -31,7 +31,14 @@ export const FenceRenderer = ({ node }: { node: FenceNode }) => {
|
||||
}, [node.id])
|
||||
|
||||
return (
|
||||
<mesh castShadow material={material} receiveShadow ref={ref} visible={node.visible} {...handlers}>
|
||||
<mesh
|
||||
castShadow
|
||||
material={material}
|
||||
receiveShadow
|
||||
ref={ref}
|
||||
visible={node.visible}
|
||||
{...handlers}
|
||||
>
|
||||
<boxGeometry args={[0, 0, 0]} />
|
||||
</mesh>
|
||||
)
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { type AnyNodeId, type RoofNode, type RoofSegmentNode, useRegistry, useScene } from '@pascal-app/core'
|
||||
import {
|
||||
type AnyNodeId,
|
||||
type RoofNode,
|
||||
type RoofSegmentNode,
|
||||
useRegistry,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { useEffect, useMemo, useRef } from 'react'
|
||||
import * as THREE from 'three'
|
||||
import { useNodeEvents } from '../../../hooks/use-node-events'
|
||||
@@ -14,8 +20,9 @@ export const RoofSegmentRenderer = ({ node }: { node: RoofSegmentNode }) => {
|
||||
|
||||
const handlers = useNodeEvents(node, 'roof-segment')
|
||||
const debugColors = useViewer((s) => s.debugColors)
|
||||
const parentNode =
|
||||
node.parentId ? (nodes[node.parentId as AnyNodeId] as RoofNode | undefined) : undefined
|
||||
const parentNode = node.parentId
|
||||
? (nodes[node.parentId as AnyNodeId] as RoofNode | undefined)
|
||||
: undefined
|
||||
const placeholderGeometry = useMemo(() => {
|
||||
const geometry = new THREE.BufferGeometry()
|
||||
geometry.setAttribute('position', new THREE.Float32BufferAttribute([], 3))
|
||||
@@ -32,24 +39,7 @@ export const RoofSegmentRenderer = ({ node }: { node: RoofSegmentNode }) => {
|
||||
}
|
||||
|
||||
return parentNode ? getRoofMaterialArray(parentNode) : null
|
||||
}, [
|
||||
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?.topMaterial,
|
||||
parentNode?.topMaterialPreset,
|
||||
parentNode?.edgeMaterial,
|
||||
parentNode?.edgeMaterialPreset,
|
||||
parentNode?.wallMaterial,
|
||||
parentNode?.wallMaterialPreset,
|
||||
])
|
||||
}, [node, parentNode])
|
||||
|
||||
const material = debugColors ? roofDebugMaterials : customMaterial || roofMaterials
|
||||
|
||||
|
||||
@@ -24,22 +24,7 @@ export const RoofRenderer = ({ node }: { node: RoofNode }) => {
|
||||
return geometry
|
||||
}, [])
|
||||
|
||||
const customMaterial = useMemo(
|
||||
() => getRoofMaterialArray(node),
|
||||
[
|
||||
node.materialPreset,
|
||||
node.material,
|
||||
node.material?.preset,
|
||||
node.material?.properties,
|
||||
node.material?.texture,
|
||||
node.topMaterial,
|
||||
node.topMaterialPreset,
|
||||
node.edgeMaterial,
|
||||
node.edgeMaterialPreset,
|
||||
node.wallMaterial,
|
||||
node.wallMaterialPreset,
|
||||
],
|
||||
)
|
||||
const customMaterial = useMemo(() => getRoofMaterialArray(node), [node])
|
||||
|
||||
const material = debugColors ? roofDebugMaterials : customMaterial || roofMaterials
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { getMaterialPresetByRef, type SlabNode, useRegistry } from '@pascal-app/core'
|
||||
import { useEffect, useMemo, useRef } from 'react'
|
||||
import * as THREE from 'three'
|
||||
import { useMemo, useRef } from 'react'
|
||||
import type { Mesh } from 'three'
|
||||
import * as THREE from 'three'
|
||||
import { useNodeEvents } from '../../../hooks/use-node-events'
|
||||
import {
|
||||
applyMaterialPresetToMaterials,
|
||||
@@ -9,6 +9,42 @@ import {
|
||||
DEFAULT_SLAB_MATERIAL,
|
||||
} from '../../../lib/materials'
|
||||
|
||||
const slabMaterialCache = new Map<string, THREE.MeshStandardMaterial>()
|
||||
|
||||
function getSlabMaterial(
|
||||
cacheKey: string,
|
||||
params: { material?: SlabNode['material']; materialPreset?: string },
|
||||
) {
|
||||
const cached = slabMaterialCache.get(cacheKey)
|
||||
if (cached) return cached
|
||||
|
||||
const preset = getMaterialPresetByRef(params.materialPreset)
|
||||
const slabMaterial = preset
|
||||
? new THREE.MeshStandardMaterial()
|
||||
: params.material
|
||||
? createMaterial(params.material).clone()
|
||||
: DEFAULT_SLAB_MATERIAL.clone()
|
||||
|
||||
if (preset) {
|
||||
// Apply the preset to the slab-owned material so async texture loads update
|
||||
// the instance we actually render after refresh as well.
|
||||
applyMaterialPresetToMaterials(slabMaterial, preset)
|
||||
}
|
||||
|
||||
// Slabs participate in the WebGPU MRT scene pass. Keeping them opaque avoids
|
||||
// pipeline variants that can fail when geometry is regenerated while a
|
||||
// transparent/custom material is attached.
|
||||
slabMaterial.transparent = false
|
||||
slabMaterial.opacity = 1
|
||||
slabMaterial.alphaMap = null
|
||||
slabMaterial.side = THREE.DoubleSide
|
||||
slabMaterial.depthWrite = true
|
||||
slabMaterial.needsUpdate = true
|
||||
|
||||
slabMaterialCache.set(cacheKey, slabMaterial)
|
||||
return slabMaterial
|
||||
}
|
||||
|
||||
export const SlabRenderer = ({ node }: { node: SlabNode }) => {
|
||||
const ref = useRef<Mesh>(null!)
|
||||
|
||||
@@ -17,30 +53,17 @@ export const SlabRenderer = ({ node }: { node: SlabNode }) => {
|
||||
const handlers = useNodeEvents(node, 'slab')
|
||||
|
||||
const material = useMemo(() => {
|
||||
const preset = getMaterialPresetByRef(node.materialPreset)
|
||||
const slabMaterial = preset
|
||||
? new THREE.MeshStandardMaterial()
|
||||
: node.material
|
||||
? createMaterial(node.material).clone()
|
||||
: DEFAULT_SLAB_MATERIAL.clone()
|
||||
const resolvedMaterial = node.material
|
||||
const resolvedMaterialPreset = node.materialPreset
|
||||
const cacheKey = JSON.stringify({
|
||||
material: resolvedMaterial ?? null,
|
||||
materialPreset: resolvedMaterialPreset ?? null,
|
||||
})
|
||||
|
||||
if (preset) {
|
||||
// Apply the preset to the slab-owned material so async texture loads update
|
||||
// the instance we actually render after refresh as well.
|
||||
applyMaterialPresetToMaterials(slabMaterial, preset)
|
||||
}
|
||||
|
||||
// Slabs participate in the WebGPU MRT scene pass. Keeping them opaque avoids
|
||||
// pipeline variants that can fail when geometry is regenerated while a
|
||||
// transparent/custom material is attached.
|
||||
slabMaterial.transparent = false
|
||||
slabMaterial.opacity = 1
|
||||
slabMaterial.alphaMap = null
|
||||
slabMaterial.side = THREE.DoubleSide
|
||||
slabMaterial.depthWrite = true
|
||||
slabMaterial.needsUpdate = true
|
||||
|
||||
return slabMaterial
|
||||
return getSlabMaterial(cacheKey, {
|
||||
material: resolvedMaterial,
|
||||
materialPreset: resolvedMaterialPreset,
|
||||
})
|
||||
}, [
|
||||
node.material,
|
||||
node.material?.preset,
|
||||
@@ -49,12 +72,6 @@ export const SlabRenderer = ({ node }: { node: SlabNode }) => {
|
||||
node.materialPreset,
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
material.dispose()
|
||||
}
|
||||
}, [material])
|
||||
|
||||
return (
|
||||
<mesh
|
||||
castShadow
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { type AnyNodeId, type StairNode, type StairSegmentNode, useRegistry, useScene } from '@pascal-app/core'
|
||||
import {
|
||||
type AnyNodeId,
|
||||
type StairNode,
|
||||
type StairSegmentNode,
|
||||
useRegistry,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef } from 'react'
|
||||
import * as THREE from 'three'
|
||||
import { useNodeEvents } from '../../../hooks/use-node-events'
|
||||
@@ -15,29 +21,13 @@ export const StairSegmentRenderer = ({ node }: { node: StairSegmentNode }) => {
|
||||
}, [node.id])
|
||||
|
||||
const handlers = useNodeEvents(node, 'stair-segment')
|
||||
const parentNode =
|
||||
node.parentId ? (nodes[node.parentId as AnyNodeId] as StairNode | undefined) : undefined
|
||||
|
||||
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 parentNode = node.parentId
|
||||
? (nodes[node.parentId as AnyNodeId] as StairNode | undefined)
|
||||
: undefined
|
||||
const material = useMemo(
|
||||
() => getStraightStairSegmentBodyMaterials(node, parentNode),
|
||||
[node, parentNode],
|
||||
)
|
||||
|
||||
const placeholderGeometry = useMemo(() => {
|
||||
const geometry = new THREE.BufferGeometry()
|
||||
|
||||
@@ -8,10 +8,14 @@ import {
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef } from 'react'
|
||||
import * as THREE from 'three'
|
||||
import { useNodeEvents } from '../../../hooks/use-node-events'
|
||||
import { createMaterial, createMaterialFromPresetRef, DEFAULT_STAIR_MATERIAL } from '../../../lib/materials'
|
||||
import {
|
||||
getStairRailingMaterial,
|
||||
createMaterial,
|
||||
createMaterialFromPresetRef,
|
||||
DEFAULT_STAIR_MATERIAL,
|
||||
} from '../../../lib/materials'
|
||||
import {
|
||||
getStairBodyMaterials,
|
||||
getStairRailingMaterial,
|
||||
type StairBodyMaterials,
|
||||
} from '../../../systems/stair/stair-materials'
|
||||
import { NodeRenderer } from '../node-renderer'
|
||||
@@ -72,33 +76,9 @@ export const StairRenderer = ({ node }: { node: StairNode }) => {
|
||||
node.material?.texture,
|
||||
])
|
||||
|
||||
const straightBodyMaterials = useMemo(
|
||||
() => getStairBodyMaterials(node),
|
||||
[
|
||||
node.material,
|
||||
node.materialPreset,
|
||||
node.railingMaterial,
|
||||
node.railingMaterialPreset,
|
||||
node.sideMaterial,
|
||||
node.sideMaterialPreset,
|
||||
node.treadMaterial,
|
||||
node.treadMaterialPreset,
|
||||
],
|
||||
)
|
||||
const straightBodyMaterials = useMemo(() => getStairBodyMaterials(node), [node])
|
||||
|
||||
const railingMaterial = useMemo(
|
||||
() => getStairRailingMaterial(node),
|
||||
[
|
||||
node.material,
|
||||
node.materialPreset,
|
||||
node.railingMaterial,
|
||||
node.railingMaterialPreset,
|
||||
node.sideMaterial,
|
||||
node.sideMaterialPreset,
|
||||
node.treadMaterial,
|
||||
node.treadMaterialPreset,
|
||||
],
|
||||
)
|
||||
const railingMaterial = useMemo(() => getStairRailingMaterial(node), [node])
|
||||
|
||||
const straightPlaceholderGeometry = useMemo(() => {
|
||||
const geometry = new THREE.BufferGeometry()
|
||||
@@ -132,7 +112,9 @@ export const StairRenderer = ({ node }: { node: StairNode }) => {
|
||||
receiveShadow
|
||||
/>
|
||||
) : null}
|
||||
{!isSegmentBasedStair ? <CurvedStairBody bodyMaterials={straightBodyMaterials} stair={node} /> : null}
|
||||
{!isSegmentBasedStair ? (
|
||||
<CurvedStairBody bodyMaterials={straightBodyMaterials} stair={node} />
|
||||
) : null}
|
||||
<StairRailings material={railingMaterial} stair={node} />
|
||||
{isSegmentBasedStair ? (
|
||||
<group name="segments-wrapper" visible={false}>
|
||||
|
||||
@@ -26,7 +26,7 @@ import { SceneRenderer } from '../renderers/scene-renderer'
|
||||
import FrameLimiter from './frame-limiter'
|
||||
import { Lights } from './lights'
|
||||
import { PerfMonitor } from './perf-monitor'
|
||||
import PostProcessing from './post-processing'
|
||||
import PostProcessing, { DEFAULT_HOVER_STYLES, type HoverStyles } from './post-processing'
|
||||
import { SelectionManager } from './selection-manager'
|
||||
import { ViewerCamera } from './viewer-camera'
|
||||
|
||||
@@ -101,12 +101,14 @@ function GPUDeviceWatcher() {
|
||||
|
||||
interface ViewerProps {
|
||||
children?: React.ReactNode
|
||||
hoverStyles?: HoverStyles
|
||||
selectionManager?: 'default' | 'custom'
|
||||
perf?: boolean
|
||||
}
|
||||
|
||||
const Viewer: React.FC<ViewerProps> = ({
|
||||
children,
|
||||
hoverStyles = DEFAULT_HOVER_STYLES,
|
||||
selectionManager = 'default',
|
||||
perf = false,
|
||||
}) => {
|
||||
@@ -165,7 +167,7 @@ const Viewer: React.FC<ViewerProps> = ({
|
||||
<WallSystem />
|
||||
<WindowSystem />
|
||||
<ZoneSystem />
|
||||
<PostProcessing />
|
||||
<PostProcessing hoverStyles={hoverStyles} />
|
||||
{/* <DebugRenderer /> */}
|
||||
<GPUDeviceWatcher />
|
||||
|
||||
|
||||
@@ -47,6 +47,28 @@ const RETRY_DELAY_MS = 500
|
||||
const DARK_BG = '#1f2433'
|
||||
const LIGHT_BG = '#ffffff'
|
||||
|
||||
export type HoverStyle = {
|
||||
visibleColor: number
|
||||
hiddenColor: number
|
||||
strength: number
|
||||
pulse: boolean
|
||||
}
|
||||
|
||||
export type HoverStyles = {
|
||||
default: HoverStyle
|
||||
} & Record<string, HoverStyle>
|
||||
|
||||
const DEFAULT_HOVER_STYLE: HoverStyle = {
|
||||
visibleColor: 0x00_aa_ff,
|
||||
hiddenColor: 0xf3_ff_47,
|
||||
strength: 5,
|
||||
pulse: true,
|
||||
}
|
||||
|
||||
export const DEFAULT_HOVER_STYLES: HoverStyles = {
|
||||
default: DEFAULT_HOVER_STYLE,
|
||||
}
|
||||
|
||||
function sanitizeOutlineObjects(objects: Object3D[]) {
|
||||
let nextIndex = 0
|
||||
|
||||
@@ -62,8 +84,12 @@ function sanitizeOutlineObjects(objects: Object3D[]) {
|
||||
objects.length = nextIndex
|
||||
}
|
||||
|
||||
const PostProcessingPasses = () => {
|
||||
const { gl: renderer, scene, camera } = useThree()
|
||||
const PostProcessingPasses = ({
|
||||
hoverStyles = DEFAULT_HOVER_STYLES,
|
||||
}: {
|
||||
hoverStyles?: HoverStyles
|
||||
}) => {
|
||||
const { gl: renderer, invalidate, scene, camera } = useThree()
|
||||
const renderPipelineRef = useRef<RenderPipeline | null>(null)
|
||||
const hasPipelineErrorRef = useRef(false)
|
||||
const retryCountRef = useRef(0)
|
||||
@@ -83,6 +109,10 @@ const PostProcessingPasses = () => {
|
||||
return l
|
||||
}, [])
|
||||
const hoverHighlightMode = useViewer((s) => s.hoverHighlightMode)
|
||||
const hoverVisibleColor = useMemo(() => uniform(new Color(DEFAULT_HOVER_STYLE.visibleColor)), [])
|
||||
const hoverHiddenColor = useMemo(() => uniform(new Color(DEFAULT_HOVER_STYLE.hiddenColor)), [])
|
||||
const hoverStrength = useMemo(() => uniform(DEFAULT_HOVER_STYLE.strength), [])
|
||||
const hoverPulseMix = useMemo(() => uniform(DEFAULT_HOVER_STYLE.pulse ? 0 : 1), [])
|
||||
|
||||
// Subscribe to projectId so the pipeline rebuilds on project switch
|
||||
const projectId = useViewer((s) => s.projectId)
|
||||
@@ -119,6 +149,23 @@ const PostProcessingPasses = () => {
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const style = hoverStyles[hoverHighlightMode] ?? hoverStyles.default
|
||||
hoverVisibleColor.value.setHex(style.visibleColor)
|
||||
hoverHiddenColor.value.setHex(style.hiddenColor)
|
||||
hoverStrength.value = style.strength
|
||||
hoverPulseMix.value = style.pulse ? 0 : 1
|
||||
invalidate()
|
||||
}, [
|
||||
hoverHiddenColor,
|
||||
hoverHighlightMode,
|
||||
hoverPulseMix,
|
||||
hoverStrength,
|
||||
hoverStyles,
|
||||
hoverVisibleColor,
|
||||
invalidate,
|
||||
])
|
||||
|
||||
// Build / rebuild the post-processing pipeline
|
||||
useEffect(() => {
|
||||
// Intentionally touch these so React/biome treat project switches and retry bumps
|
||||
@@ -248,18 +295,9 @@ const PostProcessingPasses = () => {
|
||||
.mul(selectedStrength)
|
||||
|
||||
// Hovered: blue visible, yellow hidden, pulsing
|
||||
const hoverVisibleColor = uniform(
|
||||
new Color(hoverHighlightMode === 'delete' ? 0xef_44_44 : 0x00_aa_ff),
|
||||
)
|
||||
const hoverHiddenColor = uniform(
|
||||
new Color(hoverHighlightMode === 'delete' ? 0x99_1b_1b : 0xf3_ff_47),
|
||||
)
|
||||
const hoverStrength = uniform(hoverHighlightMode === 'delete' ? 6 : 5)
|
||||
const pulsePeriod = uniform(3)
|
||||
const osc =
|
||||
hoverHighlightMode === 'delete'
|
||||
? float(1)
|
||||
: oscSine(time.div(pulsePeriod).mul(2)).mul(0.5).add(0.5) // [ 0.5, 1.0 ]
|
||||
const oscillating = oscSine(time.div(pulsePeriod).mul(2)).mul(0.5).add(0.5)
|
||||
const osc = mix(oscillating, float(1), hoverPulseMix)
|
||||
const hoverOutline = outlineNode.secondaryVisibleEdge
|
||||
.mul(hoverVisibleColor)
|
||||
.add(outlineNode.secondaryHiddenEdge.mul(hoverHiddenColor))
|
||||
@@ -298,7 +336,18 @@ const PostProcessingPasses = () => {
|
||||
}
|
||||
renderPipelineRef.current = null
|
||||
}
|
||||
}, [renderer, scene, camera, hoverHighlightMode, zoneLayers, projectId, pipelineVersion])
|
||||
}, [
|
||||
camera,
|
||||
hoverHiddenColor,
|
||||
hoverPulseMix,
|
||||
hoverStrength,
|
||||
hoverVisibleColor,
|
||||
pipelineVersion,
|
||||
projectId,
|
||||
renderer,
|
||||
scene,
|
||||
zoneLayers,
|
||||
])
|
||||
|
||||
useFrame((_, delta) => {
|
||||
// Animate background colour toward the current theme target (same lerp as AnimatedBackground)
|
||||
|
||||
Reference in New Issue
Block a user