149 lines
4.5 KiB
TypeScript

'use client'
import type { Cursor } from '@pascal-app/core'
import { ARROW_SCALE, HandleArrow, swallowNextClick } from '@pascal-app/editor'
import type { ThreeEvent } from '@react-three/fiber'
import { useThree } from '@react-three/fiber'
import { useState } from 'react'
import { OrthographicCamera } from 'three'
type Point = [number, number, number]
function consumeHandlePress(event: ThreeEvent<PointerEvent>) {
event.stopPropagation()
event.nativeEvent.stopPropagation()
event.nativeEvent.stopImmediatePropagation()
swallowNextClick()
}
/**
* Small persistent cube the user CLICKS to latch a directional handle cluster
* open (click again to close). A `tracker` HandleArrow (a tiny cube) reused so
* it shares the rig's hit-area / depth / outline treatment, sized to match the
* roof-segment pitch cube (`baseScale = zoom`, full `TRACKER_CUBE_SIZE`).
* `hoverScale = 1.15` grows it 15% on hover / while its cluster is open so it
* reads as clickable. Shared by the duct-segment and duct-fitting selection
* rigs so every editing cube is the same size.
*/
export function HandleCube({
position,
active,
onClick,
onPointerDown,
rotationY = 0,
cursor = 'grab',
}: {
position: Point
active: boolean
onClick?: () => void
onPointerDown?: (e: ThreeEvent<PointerEvent>) => void
/** Yaw (radians) so the cube can align with the run it sits on. */
rotationY?: number
cursor?: Cursor
}) {
const [hovered, setHovered] = useState(false)
const { camera } = useThree()
const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1
const baseScale = zoom
return (
<HandleArrow
cursor={cursor}
hover={hovered || active}
hoverScale={1.15}
onHoverChange={setHovered}
onPointerDown={(e) => {
consumeHandlePress(e)
if (onPointerDown) onPointerDown(e)
else onClick?.()
}}
placement={{ position, rotation: [0, rotationY, 0], baseScale }}
shape="tracker"
/>
)
}
/**
* In-world chevron arrow handle — a thin wrapper over the editor's shared
* `HandleArrow` so directional move arrows render as the same solid violet
* plate (depth-written, ink-edge outlined) the wall arrows use. Lays flat in
* the XZ plane pointing along +X (yawed by `rotationY`); `vertical` tips the
* chevron up / down for the riser pair. Scales with ortho zoom for a constant
* on-screen size.
*/
export function MoveChevron({
position,
rotationY = 0,
vertical,
cursor = 'grab',
onPointerDown,
}: {
position: Point
rotationY?: number
vertical?: 'up' | 'down'
cursor?: Cursor
onPointerDown: (e: ThreeEvent<PointerEvent>) => void
}) {
const [hovered, setHovered] = useState(false)
const { camera } = useThree()
const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1
const baseScale = zoom * ARROW_SCALE
// Tip the flat chevron up / down to point along ±Y — the same inner-rotation
// chain the wall height arrow uses.
const indicatorRotation: [number, number, number] | undefined = vertical
? [0, Math.PI / 2, vertical === 'up' ? Math.PI / 2 : -Math.PI / 2]
: undefined
return (
<HandleArrow
cursor={cursor}
hover={hovered}
indicatorRotation={indicatorRotation}
onHoverChange={setHovered}
onPointerDown={(event) => {
consumeHandlePress(event)
onPointerDown(event)
}}
placement={{ position, rotation: [0, rotationY, 0], baseScale }}
shape="chevron"
thin
/>
)
}
/**
* Rotation arc handle — the editor's `curved-arrow` (which wraps world +Y by
* default) re-oriented by an arbitrary `rotation` euler. Scales with ortho zoom
* for a constant on-screen size. The caller supplies the position + orientation
* so the same component serves a duct's single roll arc and a fitting's three
* per-axis arcs.
*/
export function RotateArc({
position,
rotation,
cursor = 'grab',
onPointerDown,
}: {
position: Point
rotation: [number, number, number]
cursor?: Cursor
onPointerDown: (e: ThreeEvent<PointerEvent>) => void
}) {
const [hovered, setHovered] = useState(false)
const { camera } = useThree()
const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1
const baseScale = zoom * ARROW_SCALE
return (
<HandleArrow
cursor={cursor}
hover={hovered}
onHoverChange={setHovered}
onPointerDown={(event) => {
consumeHandlePress(event)
onPointerDown(event)
}}
placement={{ position, rotation, baseScale }}
shape="curved-arrow"
/>
)
}