107 lines
3.6 KiB
TypeScript
107 lines
3.6 KiB
TypeScript
'use client'
|
|
|
|
import type {
|
|
DuctFittingNode,
|
|
DuctSegmentNode,
|
|
PipeFittingNode,
|
|
PipeSegmentNode,
|
|
} from '@pascal-app/core'
|
|
import { EDITOR_LAYER } from '@pascal-app/editor'
|
|
import { useMemo } from 'react'
|
|
import { Mesh, MeshBasicMaterial } from 'three'
|
|
import { buildDuctFittingGeometry } from '../duct-fitting/geometry'
|
|
import { buildDuctSegmentGeometry } from '../duct-segment/geometry'
|
|
import { buildPipeFittingGeometry } from '../pipe-fitting/geometry'
|
|
import { buildPipeSegmentGeometry } from '../pipe-segment/geometry'
|
|
import { INVALID_GHOST_COLOR, VALID_GHOST_COLOR } from './ghost-materials'
|
|
|
|
/** Indigo-400 — the shared MEP preview accent (matches the draw-tool ghost). */
|
|
export const GHOST_COLOR = '#818cf8'
|
|
export const GHOST_OPACITY = 0.55
|
|
|
|
/** Tint state for an auto-routed offset preview: green = a buildable offset
|
|
* that will mint on release, red = no valid offset at this height (the run
|
|
* lifts as a preview only and snaps back). Undefined = the neutral indigo
|
|
* preview used everywhere else. */
|
|
export type GhostTint = 'valid' | 'invalid' | undefined
|
|
|
|
function ghostColor(tint: GhostTint): number | string {
|
|
if (tint === 'valid') return VALID_GHOST_COLOR
|
|
if (tint === 'invalid') return INVALID_GHOST_COLOR
|
|
return GHOST_COLOR
|
|
}
|
|
|
|
/** Repaint every mesh in `group` as a translucent, depth-test-free preview. */
|
|
function ghostify(group: { traverse: (cb: (child: object) => void) => void }, tint: GhostTint) {
|
|
const color = ghostColor(tint)
|
|
group.traverse((child) => {
|
|
if (child instanceof Mesh) {
|
|
child.layers.set(EDITOR_LAYER)
|
|
child.material = new MeshBasicMaterial({
|
|
color,
|
|
depthTest: false,
|
|
transparent: true,
|
|
opacity: GHOST_OPACITY,
|
|
})
|
|
child.renderOrder = 999
|
|
}
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Translucent ghost of a duct fitting, built from the same geometry the
|
|
* placed node uses so the preview matches the result. The node carries its
|
|
* level-local `position` / `rotation`, applied here on the group (the
|
|
* renderer normally bakes that in).
|
|
*/
|
|
export function FittingGhost({ fitting, tint }: { fitting: DuctFittingNode; tint?: GhostTint }) {
|
|
const ghost = useMemo(() => {
|
|
const group = buildDuctFittingGeometry(fitting)
|
|
group.position.set(...fitting.position)
|
|
group.rotation.set(fitting.rotation[0], fitting.rotation[1], fitting.rotation[2])
|
|
ghostify(group, tint)
|
|
return group
|
|
}, [fitting, tint])
|
|
return <primitive object={ghost} />
|
|
}
|
|
|
|
/**
|
|
* Translucent ghost of a duct-segment run. Path coords are level-local and
|
|
* the node's transform is identity, so the built group renders at the origin
|
|
* — the same frame the fitting ghosts use.
|
|
*/
|
|
export function DuctSegmentGhost({ duct, tint }: { duct: DuctSegmentNode; tint?: GhostTint }) {
|
|
const ghost = useMemo(() => {
|
|
const group = buildDuctSegmentGeometry(duct)
|
|
ghostify(group, tint)
|
|
return group
|
|
}, [duct, tint])
|
|
return <primitive object={ghost} />
|
|
}
|
|
|
|
export function PipeFittingGhost({
|
|
fitting,
|
|
tint,
|
|
}: {
|
|
fitting: PipeFittingNode
|
|
tint?: GhostTint
|
|
}) {
|
|
const ghost = useMemo(() => {
|
|
const group = buildPipeFittingGeometry(fitting)
|
|
group.position.set(...fitting.position)
|
|
group.rotation.set(fitting.rotation[0], fitting.rotation[1], fitting.rotation[2])
|
|
ghostify(group, tint)
|
|
return group
|
|
}, [fitting, tint])
|
|
return <primitive object={ghost} />
|
|
}
|
|
|
|
export function PipeSegmentGhost({ pipe, tint }: { pipe: PipeSegmentNode; tint?: GhostTint }) {
|
|
const ghost = useMemo(() => {
|
|
const group = buildPipeSegmentGeometry(pipe)
|
|
ghostify(group, tint)
|
|
return group
|
|
}, [pipe, tint])
|
|
return <primitive object={ghost} />
|
|
}
|