Merge remote-tracking branch 'origin/main' into feat/paint-slots
# Conflicts: # packages/core/src/store/use-scene.ts # packages/editor/src/components/editor/index.tsx
This commit is contained in:
@@ -70,12 +70,16 @@ export const ParametricNodeRenderer = ({ node }: { node: AnyNode }) => {
|
||||
|
||||
const position = liveTransform?.position ?? overridePosition ?? n.position ?? [0, 0, 0]
|
||||
const rawRotation = overrideRotation ?? n.rotation
|
||||
const baseRotation: [number, number, number] =
|
||||
typeof rawRotation === 'number' ? [0, rawRotation, 0] : (rawRotation ?? [0, 0, 0])
|
||||
// The live transform carries only the plan-view Y rotation; keep the
|
||||
// node's own X/Z so 3D-oriented kinds (e.g. a duct-fitting riser at
|
||||
// X=π/2) don't visually flatten to horizontal mid-drag. Matches the
|
||||
// move tool's commit, which also replaces only the Y component.
|
||||
const rotation: [number, number, number] =
|
||||
liveTransform?.rotation !== undefined
|
||||
? [0, liveTransform.rotation, 0]
|
||||
: typeof rawRotation === 'number'
|
||||
? [0, rawRotation, 0]
|
||||
: (rawRotation ?? [0, 0, 0])
|
||||
? [baseRotation[0], liveTransform.rotation, baseRotation[2]]
|
||||
: baseRotation
|
||||
|
||||
return (
|
||||
<group
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { Canvas, extend, type ThreeToJSXElements, useFrame, useThree } from '@react-three/fiber'
|
||||
import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react'
|
||||
import { forwardRef, useEffect, useImperativeHandle, useLayoutEffect, useRef } from 'react'
|
||||
import * as THREE from 'three/webgpu'
|
||||
import { hasDrawableGeometry } from '../../lib/drawable-geometry'
|
||||
import { PERF_OVERLAY_ENABLED, pushGpuSample } from '../../lib/gpu-perf'
|
||||
@@ -275,6 +275,7 @@ interface ViewerProps {
|
||||
perf?: boolean
|
||||
useBvh?: boolean
|
||||
renderContext?: RenderContext
|
||||
transparent?: boolean
|
||||
defaultRender?: {
|
||||
shading?: RenderShading
|
||||
textures?: boolean
|
||||
@@ -318,6 +319,7 @@ const Viewer = forwardRef<ViewerHandle, ViewerProps>(function Viewer(
|
||||
perf = false,
|
||||
useBvh = true,
|
||||
renderContext = 'editor',
|
||||
transparent,
|
||||
defaultRender,
|
||||
isolate,
|
||||
sceneReadyKey,
|
||||
@@ -348,6 +350,16 @@ const Viewer = forwardRef<ViewerHandle, ViewerProps>(function Viewer(
|
||||
}, [isolate])
|
||||
|
||||
const isDark = useViewer((state) => getSceneTheme(state.sceneTheme).appearance === 'dark')
|
||||
const transparentBackground = useViewer((state) => state.transparentBackground)
|
||||
useLayoutEffect(() => {
|
||||
if (transparent === undefined) return
|
||||
|
||||
useViewer.getState().setTransparentBackground(transparent)
|
||||
return () => {
|
||||
useViewer.getState().setTransparentBackground(false)
|
||||
}
|
||||
}, [transparent])
|
||||
|
||||
const defaultShading = defaultRender?.shading
|
||||
const defaultTextures = defaultRender?.textures
|
||||
const defaultColorPreset = defaultRender?.colorPreset
|
||||
@@ -392,7 +404,9 @@ const Viewer = forwardRef<ViewerHandle, ViewerProps>(function Viewer(
|
||||
return (
|
||||
<Canvas
|
||||
camera={{ position: [50, 50, 50], fov: 50 }}
|
||||
className={`transition-colors duration-700 ${isDark ? 'bg-[#1f2433]' : 'bg-[#fafafa]'}`}
|
||||
className={`transition-colors duration-700 ${
|
||||
transparentBackground ? 'bg-transparent' : isDark ? 'bg-[#1f2433]' : 'bg-[#fafafa]'
|
||||
}`}
|
||||
dpr={[1, maxDpr]}
|
||||
frameloop="never"
|
||||
gl={
|
||||
@@ -402,7 +416,7 @@ const Viewer = forwardRef<ViewerHandle, ViewerProps>(function Viewer(
|
||||
if (cached) return cached
|
||||
const promise = (async () => {
|
||||
try {
|
||||
const renderer = new THREE.WebGPURenderer(props as any)
|
||||
const renderer = new THREE.WebGPURenderer({ ...(props as any), alpha: true })
|
||||
renderer.toneMapping = THREE.ACESFilmicToneMapping
|
||||
renderer.toneMappingExposure = getSceneTheme(
|
||||
useViewer.getState().sceneTheme,
|
||||
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
oscSine,
|
||||
output,
|
||||
pass,
|
||||
premultiplyAlpha,
|
||||
renderOutput,
|
||||
sample,
|
||||
time,
|
||||
uniform,
|
||||
@@ -180,6 +182,8 @@ const PostProcessingPasses = ({
|
||||
const projectId = useViewer((s) => s.projectId)
|
||||
const shading = useViewer((s) => s.shading)
|
||||
const edges = useViewer((s) => s.edges)
|
||||
const inkOpacityOverride = useViewer((s) => s.inkOpacity)
|
||||
const transparentBackground = useViewer((s) => s.transparentBackground)
|
||||
const lastProjectIdRef = useRef(projectId)
|
||||
|
||||
// Bump this to force a pipeline rebuild (used by retry logic)
|
||||
@@ -272,7 +276,7 @@ const PostProcessingPasses = ({
|
||||
// Same 1px line thickness for both (soft's thickness is the nice one);
|
||||
// strong reads heavier purely by being fully solid vs soft's lighter 50%.
|
||||
const inkRadius = 1
|
||||
const inkOpacity = edges === 'strong' ? 1 : 0.5
|
||||
const inkOpacity = inkOpacityOverride ?? (edges === 'strong' ? 1 : 0.5)
|
||||
|
||||
console.log('[viewer/post-processing] Building pipeline', {
|
||||
version: pipelineVersion,
|
||||
@@ -282,6 +286,7 @@ const PostProcessingPasses = ({
|
||||
perfDisable,
|
||||
projectId,
|
||||
shading,
|
||||
transparentBackground,
|
||||
rendererCtor: (renderer as any).constructor?.name,
|
||||
width,
|
||||
height,
|
||||
@@ -419,6 +424,7 @@ const PostProcessingPasses = ({
|
||||
// Single merged outline node: one shared depth pass for both selected + hovered groups.
|
||||
const outliner = useViewer.getState().outliner
|
||||
let compositeWithOutlines = sceneColor
|
||||
let visualAlpha = contentAlpha
|
||||
if (outlineEnabled) {
|
||||
const outlineNode = mergedOutline(scene, camera, {
|
||||
primaryObjects: outliner.selectedObjects,
|
||||
@@ -446,6 +452,11 @@ const PostProcessingPasses = ({
|
||||
.mul(hoverStrength)
|
||||
.mul(osc)
|
||||
|
||||
const outlineAlpha = outlineNode.primaryVisibleEdge
|
||||
.max(outlineNode.primaryHiddenEdge)
|
||||
.max(outlineNode.secondaryVisibleEdge)
|
||||
.max(outlineNode.secondaryHiddenEdge)
|
||||
visualAlpha = visualAlpha.max(outlineAlpha)
|
||||
compositeWithOutlines = vec4(
|
||||
add(sceneColor.rgb, selectedOutline.add(hoverOutline)),
|
||||
sceneColor.a,
|
||||
@@ -456,9 +467,22 @@ const PostProcessingPasses = ({
|
||||
// Editor overlays painted on top by their own alpha — they never get inked,
|
||||
// AO'd, or outlined, and always read crisp regardless of scene depth.
|
||||
const withOverlay = mix(composited, overlayColor.rgb, overlayColor.a)
|
||||
const finalOutput = vec4(withOverlay, float(1))
|
||||
let finalOutput: ReturnType<typeof premultiplyAlpha> | ReturnType<typeof vec4> = vec4(
|
||||
withOverlay,
|
||||
float(1),
|
||||
)
|
||||
if (transparentBackground) {
|
||||
const overlayAlpha = overlayColor.a
|
||||
const alpha = overlayAlpha.add(visualAlpha.mul(overlayAlpha.oneMinus()))
|
||||
const straightRgb = overlayColor.rgb
|
||||
.mul(overlayAlpha)
|
||||
.add(compositeWithOutlines.rgb.mul(visualAlpha).mul(overlayAlpha.oneMinus()))
|
||||
.div(alpha.max(float(0.00001)))
|
||||
finalOutput = premultiplyAlpha(renderOutput(vec4(straightRgb, alpha)))
|
||||
}
|
||||
|
||||
const renderPipeline = new RenderPipeline(renderer as unknown as WebGPURenderer)
|
||||
renderPipeline.outputColorTransform = !transparentBackground
|
||||
renderPipeline.outputNode = finalOutput
|
||||
renderPipelineRef.current = renderPipeline
|
||||
retryCountRef.current = 0
|
||||
@@ -496,11 +520,13 @@ const PostProcessingPasses = ({
|
||||
hoverStrength,
|
||||
hoverVisibleColor,
|
||||
edges,
|
||||
inkOpacityOverride,
|
||||
pipelineVersion,
|
||||
projectId,
|
||||
renderer,
|
||||
scene,
|
||||
shading,
|
||||
transparentBackground,
|
||||
size.height,
|
||||
size.width,
|
||||
zoneLayers,
|
||||
@@ -527,7 +553,7 @@ const PostProcessingPasses = ({
|
||||
if (PERF_POST_FX_DISABLED || hasPipelineErrorRef.current || !renderPipelineRef.current) {
|
||||
try {
|
||||
if ((renderer as any).setClearAlpha) {
|
||||
;(renderer as any).setClearAlpha(1)
|
||||
;(renderer as any).setClearAlpha(transparentBackground ? 0 : 1)
|
||||
}
|
||||
const submittedAt = PERF_OVERLAY_ENABLED ? performance.now() : 0
|
||||
;(renderer as any).render(scene, camera)
|
||||
|
||||
@@ -36,52 +36,60 @@ export function useNodeEvents<K extends AnyNodeType>(node: NodeByKind<K>, type:
|
||||
emitter.emit(eventKey, payload as never)
|
||||
}
|
||||
|
||||
// Suppress node pointer events while an interaction drag is in
|
||||
// progress. `cameraDragging` covers orbit/pan/dolly; `inputDragging`
|
||||
// covers host-driven drags (editor handle arrows etc.). Without
|
||||
// this, the synthesized click on pointerup would reroute selection
|
||||
// to whatever mesh the cursor lands on at release.
|
||||
const isInteractionActive = () => {
|
||||
// Camera drags (orbit / pan / dolly) suppress ALL node pointer events.
|
||||
//
|
||||
// `inputDragging` (host-driven drags: handle arrows, press-drag moves)
|
||||
// additionally suppresses the SELECTION events — without it the click
|
||||
// synthesized on pointer-release would reroute selection to whatever mesh
|
||||
// sits under the cursor at release. It must NOT suppress the SPATIAL events
|
||||
// (`enter` / `move` / `leave`): a surface-following move tool — a door /
|
||||
// window sliding along a wall — runs WITH `inputDragging` set and depends on
|
||||
// those events to track the cursor. Consumers that should ignore drag-time
|
||||
// spatial events gate on `inputDragging` themselves (the editor's hover and
|
||||
// paint paths, box-select), so emitting them during a drag only reaches the
|
||||
// active move tool that wants them.
|
||||
const spatialSuppressed = () => useViewer.getState().cameraDragging
|
||||
const selectionSuppressed = () => {
|
||||
const s = useViewer.getState()
|
||||
return s.cameraDragging || s.inputDragging
|
||||
}
|
||||
|
||||
return {
|
||||
onPointerDown: (e: ThreeEvent<PointerEvent>) => {
|
||||
if (isInteractionActive()) return
|
||||
if (selectionSuppressed()) return
|
||||
if (e.button !== 0) return
|
||||
emit('pointerdown', e)
|
||||
},
|
||||
onPointerUp: (e: ThreeEvent<PointerEvent>) => {
|
||||
if (isInteractionActive()) return
|
||||
if (selectionSuppressed()) return
|
||||
if (e.button !== 0) return
|
||||
emit('pointerup', e)
|
||||
// Synthesize a click event on pointer up to be more forgiving than R3F's default onClick
|
||||
// which often fails if the mouse moves even 1 pixel.
|
||||
emit('click', e)
|
||||
},
|
||||
onClick: (e: ThreeEvent<PointerEvent>) => {
|
||||
onClick: (_e: ThreeEvent<PointerEvent>) => {
|
||||
// Disable default R3F click since we synthesize it on pointerup
|
||||
// This prevents double-clicks from firing twice.
|
||||
},
|
||||
onPointerEnter: (e: ThreeEvent<PointerEvent>) => {
|
||||
if (isInteractionActive()) return
|
||||
if (spatialSuppressed()) return
|
||||
emit('enter', e)
|
||||
},
|
||||
onPointerLeave: (e: ThreeEvent<PointerEvent>) => {
|
||||
if (isInteractionActive()) return
|
||||
if (spatialSuppressed()) return
|
||||
emit('leave', e)
|
||||
},
|
||||
onPointerMove: (e: ThreeEvent<PointerEvent>) => {
|
||||
if (isInteractionActive()) return
|
||||
if (spatialSuppressed()) return
|
||||
emit('move', e)
|
||||
},
|
||||
onDoubleClick: (e: ThreeEvent<PointerEvent>) => {
|
||||
if (isInteractionActive()) return
|
||||
if (selectionSuppressed()) return
|
||||
emit('double-click', e)
|
||||
},
|
||||
onContextMenu: (e: ThreeEvent<PointerEvent>) => {
|
||||
if (isInteractionActive()) return
|
||||
if (selectionSuppressed()) return
|
||||
emit('context-menu', e)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -92,7 +92,7 @@ export {
|
||||
createColumnTorusGeometry,
|
||||
} from './systems/column/column-geometry'
|
||||
export { DoorAnimationSystem } from './systems/door/door-animation-system'
|
||||
export { DoorSystem } from './systems/door/door-system'
|
||||
export { buildDoorPreviewMesh, DoorSystem } from './systems/door/door-system'
|
||||
export { ElevatorInteractionSystem } from './systems/elevator/elevator-interaction-system'
|
||||
// Fence system follows the wall re-export pattern — composed into the
|
||||
// registry-driven fence definition's `def.system`. Removed in Phase 6
|
||||
@@ -158,5 +158,5 @@ export { getVisibleWallMaterials } from './systems/wall/wall-materials'
|
||||
// removed in Phase 6 when the legacy mount points are deleted.
|
||||
export { WallSystem } from './systems/wall/wall-system'
|
||||
export { WindowAnimationSystem } from './systems/window/window-animation-system'
|
||||
export { WindowSystem } from './systems/window/window-system'
|
||||
export { buildWindowPreviewMesh, WindowSystem } from './systems/window/window-system'
|
||||
export { ZoneSystem } from './systems/zone/zone-system'
|
||||
|
||||
@@ -75,6 +75,13 @@ type ViewerState = {
|
||||
showGrid: boolean
|
||||
setShowGrid: (show: boolean) => void
|
||||
|
||||
transparentBackground: boolean
|
||||
setTransparentBackground: (transparent: boolean) => void
|
||||
|
||||
// Embed-controlled ink-edge opacity override (null = use the per-mode default).
|
||||
inkOpacity: number | null
|
||||
setInkOpacity: (opacity: number | null) => void
|
||||
|
||||
projectId: string | null
|
||||
setProjectId: (id: string | null) => void
|
||||
projectPreferences: Record<
|
||||
@@ -283,6 +290,12 @@ const useViewer = create<ViewerState>()(
|
||||
return { showGrid: show, projectPreferences }
|
||||
}),
|
||||
|
||||
transparentBackground: false,
|
||||
setTransparentBackground: (transparent) => set({ transparentBackground: transparent }),
|
||||
|
||||
inkOpacity: null,
|
||||
setInkOpacity: (opacity) => set({ inkOpacity: opacity }),
|
||||
|
||||
projectId: null,
|
||||
setProjectId: (id) =>
|
||||
set((state) => {
|
||||
|
||||
@@ -2606,3 +2606,13 @@ function syncDoorCutout(node: DoorNode, mesh: THREE.Mesh) {
|
||||
}
|
||||
cutout.visible = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a fresh door mesh for preview/ghost rendering.
|
||||
* Returns a mesh with an invisible hitbox root and visible children (frame, panels, hardware).
|
||||
*/
|
||||
export function buildDoorPreviewMesh(node: DoorNode): THREE.Mesh {
|
||||
const mesh = new THREE.Mesh()
|
||||
updateDoorMesh(node, mesh)
|
||||
return mesh
|
||||
}
|
||||
|
||||
@@ -68,6 +68,14 @@ export const GeometrySystem = () => {
|
||||
// shelf dirties the shelf without altering its boards.
|
||||
const builtGeometryKeyRef = useRef<Map<string, string>>(new Map())
|
||||
|
||||
// Re-mark every geometry-backed node dirty whenever a viewer appearance
|
||||
// value changes, so `def.geometry` builders re-run and pick up the new
|
||||
// shading / texture / preset / theme. These four are deliberate re-run
|
||||
// TRIGGERS, not values read in the body — the effect re-fires on any
|
||||
// change. They're primitives (stable by value), so listing them is safe;
|
||||
// biome flags them as "unnecessary" because the body doesn't reference
|
||||
// them, but dropping them silently breaks appearance-mode switching.
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: shading/textures/colorPreset/sceneTheme are intentional re-run triggers; removing them stops geometry from rebuilding on appearance change.
|
||||
useEffect(() => {
|
||||
const nodes = useScene.getState().nodes
|
||||
for (const node of Object.values(nodes)) {
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { type LevelNode, sceneRegistry, useScene } from '@pascal-app/core'
|
||||
import { getLevelHeight, 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 EXPLODED_GAP = 5
|
||||
|
||||
@@ -40,7 +39,11 @@ export const LevelSystem = () => {
|
||||
obj.position.y = lerp(obj.position.y, targetY, delta * 12) // Smoothly animate to new Y position
|
||||
obj.visible = levelMode !== 'solo' || level?.id === selectedLevel || !selectedLevel
|
||||
|
||||
cumulativeY += getLevelHeight(levelId, nodes)
|
||||
cumulativeY += getLevelHeight(
|
||||
levelId,
|
||||
nodes,
|
||||
(wallId) => sceneRegistry.nodes.get(wallId)?.position.y,
|
||||
)
|
||||
}
|
||||
}, 5) // Using a lower priority so it runs after transforms from other systems have settled
|
||||
return null
|
||||
|
||||
@@ -1,53 +1,4 @@
|
||||
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
|
||||
}
|
||||
import { getLevelHeight, type LevelNode, sceneRegistry, useScene } from '@pascal-app/core'
|
||||
|
||||
/**
|
||||
* Instantly snaps all level Objects3D to their true stacked Y positions
|
||||
@@ -90,7 +41,11 @@ export function snapLevelsToTruePositions(): () => void {
|
||||
for (const { levelId, obj } of entries) {
|
||||
obj.position.y = cumulativeY
|
||||
obj.visible = true
|
||||
cumulativeY += getLevelHeight(levelId, nodes)
|
||||
cumulativeY += getLevelHeight(
|
||||
levelId,
|
||||
nodes,
|
||||
(wallId) => sceneRegistry.nodes.get(wallId)?.position.y,
|
||||
)
|
||||
}
|
||||
|
||||
return () => {
|
||||
|
||||
@@ -3648,3 +3648,13 @@ function syncWindowCutout(node: WindowNode, mesh: THREE.Mesh) {
|
||||
}
|
||||
cutout.visible = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a fresh window mesh for preview/ghost rendering.
|
||||
* Returns a mesh with an invisible hitbox root and visible children (frame, glass, sash, hardware).
|
||||
*/
|
||||
export function buildWindowPreviewMesh(node: WindowNode): THREE.Mesh {
|
||||
const mesh = new THREE.Mesh()
|
||||
updateWindowMesh(node, mesh)
|
||||
return mesh
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user