Merge remote-tracking branch 'origin/main' into feat/baked-glb-export

# Conflicts:
#	packages/editor/src/components/editor/export-manager.tsx
This commit is contained in:
Wassim SAMAD
2026-06-25 12:34:24 -04:00
141 changed files with 15999 additions and 2062 deletions
@@ -51,6 +51,13 @@ const CHEVRON_DEPTH = 0.08
const CHEVRON_BEVEL_THICKNESS = 0.035
const CHEVRON_BEVEL_SIZE = 0.03
const CHEVRON_BEVEL_SEGMENTS = 10
// Slimmer extrude profile matching the legacy wall side handles
// (`wall-move-side-handles.tsx`) — opt-in via the `thin` prop so the chunkier
// default is preserved for every other handle that uses the shared chevron.
const CHEVRON_THIN_DEPTH = 0.045
const CHEVRON_THIN_BEVEL_THICKNESS = 0.018
const CHEVRON_THIN_BEVEL_SIZE = 0.02
const CHEVRON_THIN_BEVEL_SEGMENTS = 8
const MOVE_CROSS_HALF_LENGTH = 0.36
const MOVE_CROSS_SHAFT_HALF_WIDTH = 0.03
const MOVE_CROSS_HEAD_HALF_WIDTH = 0.12
@@ -64,7 +71,7 @@ const ROTATE_HANDLE_HALF_SWEEP = Math.PI / 3
const ROTATE_RIBBON_HALF_WIDTH = 0.02
const ROTATE_HEAD_HALF_WIDTH = 0.045
const TRACKER_CUBE_SIZE = 0.16
export const CORNER_HEX_RADIUS = 0.16
export const CORNER_HEX_RADIUS = 0.11
export type HandleArrowShape = 'chevron' | 'cross' | 'curved-arrow' | 'tracker' | 'corner-picker'
export type HandleArrowInputShape = HandleArrowShape | 'arrow' | 'move-cross'
@@ -90,6 +97,8 @@ export type HandleArrowProps = {
indicatorRotation?: readonly [number, number, number]
onPointerEnter?: PointerHandler
onPointerLeave?: PointerHandler
// Extrude the slimmer wall-handle chevron profile (chevron shape only).
thin?: boolean
}
function normalizeHandleArrowShape(shape: HandleArrowInputShape, cursor: Cursor): HandleArrowShape {
@@ -179,8 +188,9 @@ export function createRotateArrowHandleGeometry() {
// Reused chevron+shaft silhouette. The chevron points along +X by default;
// callers rotate it around Y for Z-axis handles and into a vertical frame for
// Y-axis handles.
export function createArrowHandleGeometry() {
// Y-axis handles. `thin` extrudes the slimmer wall-handle profile.
export function createArrowHandleGeometry(thin = false) {
const depth = thin ? CHEVRON_THIN_DEPTH : CHEVRON_DEPTH
const shape = new Shape()
shape.moveTo(CHEVRON_MAX_X, 0)
shape.lineTo(CHEVRON_NOTCH_X, CHEVRON_HALF_WIDTH)
@@ -191,16 +201,16 @@ export function createArrowHandleGeometry() {
shape.lineTo(CHEVRON_NOTCH_X, -CHEVRON_HALF_WIDTH)
shape.lineTo(CHEVRON_MAX_X, 0)
const geometry = new ExtrudeGeometry(shape, {
depth: CHEVRON_DEPTH,
depth,
bevelEnabled: true,
bevelThickness: CHEVRON_BEVEL_THICKNESS,
bevelSize: CHEVRON_BEVEL_SIZE,
bevelThickness: thin ? CHEVRON_THIN_BEVEL_THICKNESS : CHEVRON_BEVEL_THICKNESS,
bevelSize: thin ? CHEVRON_THIN_BEVEL_SIZE : CHEVRON_BEVEL_SIZE,
bevelOffset: 0,
bevelSegments: CHEVRON_BEVEL_SEGMENTS,
bevelSegments: thin ? CHEVRON_THIN_BEVEL_SEGMENTS : CHEVRON_BEVEL_SEGMENTS,
curveSegments: 16,
steps: 1,
})
geometry.translate(0, 0, -CHEVRON_DEPTH / 2)
geometry.translate(0, 0, -depth / 2)
geometry.rotateX(-Math.PI / 2)
geometry.computeVertexNormals()
geometry.computeBoundingSphere()
@@ -314,8 +324,8 @@ export function createEndpointHitAreaGeometry(radius: number) {
return geometry
}
function createHandleArrowGeometry(shape: HandleArrowShape) {
if (shape === 'chevron') return createArrowHandleGeometry()
function createHandleArrowGeometry(shape: HandleArrowShape, thin = false) {
if (shape === 'chevron') return createArrowHandleGeometry(thin)
if (shape === 'cross') return createMoveCrossHandleGeometry()
if (shape === 'curved-arrow') return createRotateArrowHandleGeometry()
if (shape === 'tracker') {
@@ -459,9 +469,10 @@ export function HandleArrow({
onPointerDown,
onPointerEnter,
onPointerLeave,
thin = false,
}: HandleArrowProps) {
const visualShape = normalizeHandleArrowShape(shape, cursor)
const geometry = useMemo(() => createHandleArrowGeometry(visualShape), [visualShape])
const geometry = useMemo(() => createHandleArrowGeometry(visualShape, thin), [visualShape, thin])
const hitGeometry = useMemo(() => createHandleArrowHitGeometry(visualShape), [visualShape])
const indicatorMaterial = useHandleArrowMaterial(visualShape)
const hitMaterial = useInvisibleHitAreaMaterial()
@@ -1147,6 +1147,7 @@ export default function Editor({
<CeilingSystem />
<RoofEditSystem />
<StairEditSystem />
{isFirstPersonMode && <FirstPersonControls />}
<CustomCameraControls />
<ThumbnailGenerator onThumbnailCapture={onThumbnailCapture} />
<InteractiveSystem />
@@ -1205,8 +1206,14 @@ export default function Editor({
{!isLoading && isPreviewMode ? (
<div className="dark flex h-full w-full flex-col bg-neutral-100 text-foreground">
<ViewerOverlay onBack={() => useEditor.getState().setPreviewMode(false)} />
<div className="h-full w-full">{previewViewerContent}</div>
{isFirstPersonMode ? (
<FirstPersonOverlay onExit={() => useEditor.getState().setFirstPersonMode(false)} />
) : (
<ViewerOverlay onBack={() => useEditor.getState().setPreviewMode(false)} />
)}
<div className="h-full w-full" data-pascal-viewer-3d>
{previewViewerContent}
</div>
</div>
) : (
<>
@@ -1270,8 +1277,14 @@ export default function Editor({
{!isLoading && isPreviewMode ? (
<>
<ViewerOverlay onBack={() => useEditor.getState().setPreviewMode(false)} />
<div className="h-full w-full">{previewViewerContent}</div>
{isFirstPersonMode ? (
<FirstPersonOverlay onExit={() => useEditor.getState().setFirstPersonMode(false)} />
) : (
<ViewerOverlay onBack={() => useEditor.getState().setPreviewMode(false)} />
)}
<div className="h-full w-full" data-pascal-viewer-3d>
{previewViewerContent}
</div>
</>
) : (
<>
@@ -9,6 +9,7 @@ import {
DEFAULT_ANGLE_STEP,
type HandleDescriptor,
type HandlePortal,
type LatchHandle,
type LinearResizeHandle,
nodeRegistry,
type RadialResizeHandle,
@@ -109,11 +110,16 @@ export {
ARROW_COLOR,
ARROW_HOVER_COLOR,
ARROW_SCALE,
createArrowHandleGeometry,
createArrowHitAreaGeometry,
createEndpointHitAreaGeometry,
createMoveCrossHandleGeometry,
createRotateArrowHandleGeometry,
createRotateArrowHitAreaGeometry,
HandleArrow,
type HandleArrowInputShape,
type HandleArrowPlacement,
type HandleArrowProps,
HIT_AREA_MARGIN,
InvisibleHandleHitArea,
NO_RAYCAST,
@@ -369,6 +375,21 @@ function NodeArrowHandlesForNode({
// hook count between renders and trip React's rules-of-hooks check.
const [activeIndex, setActiveIndex] = useState<number | null>(null)
const [preDragNode, setPreDragNode] = useState<AnyNode | null>(null)
// Latch groups currently toggled open. A `latch` cube descriptor flips its
// group here on click; arrows tagged with a `latchGroup` only render while
// their group is in this set. Local to this mount, so it resets on deselect
// (the rig remounts per selection — see the `key` on NodeArrowHandlesForNode).
const [openLatchGroups, setOpenLatchGroups] = useState<ReadonlySet<string>>(() => new Set())
const toggleLatchGroup = useMemo(
() => (group: string) =>
setOpenLatchGroups((prev) => {
const next = new Set(prev)
if (next.has(group)) next.delete(group)
else next.add(group)
return next
}),
[],
)
const dragControls = useMemo<HandleDragControls>(
() => ({
onStart: (index: number, snapshot: AnyNode) => {
@@ -399,21 +420,36 @@ function NodeArrowHandlesForNode({
// here, or they'd lag behind the moving item.
const activeIsTranslate = activeIndex !== null && descriptors[activeIndex]?.kind === 'translate'
const arrows = descriptors.map((descriptor, index) => (
<ArrowHandle
activeIndex={activeIndex}
descriptor={descriptor}
dragControls={dragControls}
handleIndex={index}
// Descriptors come from a per-node-kind static list, so index is a
// stable identity within this node's selection cycle.
key={index}
liveNode={node}
preDragNode={preDragNode}
rideObject={arrowFrame}
suppressFreeze={activeIsTranslate}
/>
))
const arrows = descriptors.map((descriptor, index) => {
// A `latch` cube toggles its group's visibility; render it always.
if (descriptor.kind === 'latch') {
return (
<LatchCube
descriptor={descriptor}
key={index}
node={node}
onToggle={toggleLatchGroup}
open={openLatchGroups.has(descriptor.group)}
/>
)
}
// Arrows tagged with a latch group stay hidden until that group is open.
const latchGroup = descriptor.kind === 'linear-resize' ? descriptor.latchGroup : undefined
if (latchGroup && !openLatchGroups.has(latchGroup)) return null
return (
<ArrowHandle
activeIndex={activeIndex}
descriptor={descriptor}
dragControls={dragControls}
handleIndex={index}
key={index}
liveNode={node}
preDragNode={preDragNode}
rideObject={arrowFrame}
suppressFreeze={activeIsTranslate}
/>
)
})
return createPortal(
<group ref={outerRef}>
@@ -668,6 +704,11 @@ function LinearArrow({
? 1
: -1
// Last value an emitted resize tick fired at — a new tick fires only
// when the (snapped + clamped) value actually changes, so the cue
// tracks real size steps instead of every sub-pixel pointer jitter.
let lastTickValue = initialValue
return {
overrideId,
onBegin: () => {
@@ -695,6 +736,10 @@ function LinearArrow({
? snapScalar(rawNext, gridSnapStep)
: rawNext
const next = Math.min(maxBound, Math.max(minBound, snappedNext))
if (next !== lastTickValue) {
lastTickValue = next
sfxEmitter.emit('sfx:resize')
}
const patch = descriptor.apply(initialNode as never, next, sceneApi) as Partial<AnyNode>
// Let the kind publish live guides for the edge being resized.
onDrag?.({ ...(initialNode as object), ...patch } as AnyNode, sceneApi)
@@ -708,10 +753,19 @@ function LinearArrow({
// X+Z rotation chain matching DoorHeightArrowHandle. When the handle
// sits below the node (placement Y < 0, e.g. window bottom arrow),
// flip the Z rotation so the chevron points outward (downward).
//
// For axis === 'x' with `faceNormal` (wall-mounted opening width arrows),
// roll the blade 90° about its own pointing (X) axis so it stands up from
// the horizontal XZ plane into the node's facing plane (XY = the wall
// face) — otherwise the blade is seen edge-on from the front.
const faceNormalX =
descriptor.kind === 'linear-resize' && descriptor.axis === 'x' && descriptor.faceNormal === true
const innerRotation: [number, number, number] =
descriptor.axis === 'y'
? [0, Math.PI / 2, position[1] < 0 ? -Math.PI / 2 : Math.PI / 2]
: [0, 0, 0]
: faceNormalX
? [Math.PI / 2, 0, 0]
: [0, 0, 0]
// Optional guide decoration — linear handles use it for curved-stair
// width / inner-radius rings; radial handles use it for the column's
@@ -797,6 +851,7 @@ function LinearArrow({
onPointerDown={activate}
placement={{ position, rotation: [0, rotationY, 0], baseScale }}
shape="chevron"
thin
>
{showLabel ? <DimensionLabel position={[0, 0.22, 0]} text={labelText} /> : null}
</HandleArrow>
@@ -1192,6 +1247,7 @@ function ArcArrow({
baseScale,
}}
shape={isRotateShape ? 'curved-arrow' : 'chevron'}
thin
/>
</>
)
@@ -1314,6 +1370,56 @@ function TapActionArrow({
onPointerDown={onActivate}
placement={{ position, rotation, baseScale }}
shape={shape === 'move-cross' ? 'move-cross' : 'chevron'}
thin
/>
)
}
// Click-to-latch cube. A persistent grip (the `tracker` cube) that toggles
// the visibility of every arrow tagged with its `latchGroup` on click. Sized
// to match the duct selection cube (`baseScale = zoom`, full TRACKER_CUBE_SIZE)
// so every latch grip reads the same across the app. Stays highlighted while
// its group is open so the user can tell it's engaged.
function LatchCube({
descriptor,
node,
open,
onToggle,
}: {
descriptor: LatchHandle<AnyNode>
node: AnyNode
open: boolean
onToggle: (group: string) => void
}) {
const [isHovered, setIsHovered] = useState(false)
const { camera } = useThree()
const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1
const baseScale = zoom
const placementSceneApi = useMemo(() => createSceneApi(useScene), [])
const position = descriptor.placement.position(node, placementSceneApi)
const rotationY = descriptor.placement.rotationY?.(node, placementSceneApi) ?? 0
// Route through the shared tap path so the cube click is swallowed before it
// reaches the select tool — stops R3F propagation, suppresses box-select, and
// eats the trailing DOM click that would otherwise select the host node.
const onPointerDown = useHandleDrag({
kind: 'tap',
onTap: () => {
setIsHovered(false)
onToggle(descriptor.group)
},
})
return (
<HandleArrow
cursor="grab"
hover={isHovered || open}
hoverScale={1.15}
onHoverChange={setIsHovered}
onPointerDown={onPointerDown}
placement={{ position, rotation: [0, rotationY, 0], baseScale }}
shape="tracker"
/>
)
}
@@ -54,7 +54,7 @@ const ARROW_HOVER_COLOR = '#a5b4fc'
// Match the door arrows: scale the rendered chevron down to ~two-thirds
// so the in-world handles read as a single UI family.
const ARROW_SCALE = 0.65
const CORNER_HEX_RADIUS = 0.16
const CORNER_HEX_RADIUS = 0.11
const CORNER_DASH_SIZE = 0.1
const CORNER_GAP_SIZE = 0.07
const CORNER_DASH_THICKNESS = 0.006
@@ -12,12 +12,28 @@ interface CursorSphereProps extends Omit<ThreeElements['group'], 'ref'> {
depthWrite?: boolean
showTooltip?: boolean
height?: number
/**
* Put the bright marker dot at the TIP of the vertical line (y = height)
* instead of on the ground ring. Used when the point being placed hangs
* above the floor (e.g. duct drawn against the ceiling): the dot rides at
* the cursor / placement point while the line drops to a floor ring that
* keeps the plan position readable.
*/
dotAtTip?: boolean
/** Custom tooltip content — overrides the auto-detected build tool icon */
tooltipContent?: React.ReactNode
}
export const CursorSphere = forwardRef<Group, CursorSphereProps>(function CursorSphere(
{ color = '#818cf8', showTooltip = true, height = 2.5, visible = true, tooltipContent, ...props },
{
color = '#818cf8',
showTooltip = true,
height = 2.5,
dotAtTip = false,
visible = true,
tooltipContent,
...props
},
ref,
) {
const tool = useEditor((s) => s.tool)
@@ -39,19 +55,23 @@ export const CursorSphere = forwardRef<Group, CursorSphereProps>(function Cursor
return (
<group ref={ref} {...props} visible={isVisible}>
{/* Flat marker on the ground */}
{/* Flat marker on the ground. The bright center dot moves to the tip
of the line in `dotAtTip` mode (the placement point hangs above the
floor), leaving a faint ring here so the plan position stays read. */}
<group rotation={[-Math.PI / 2, 0, 0]}>
{/* Center dot */}
<mesh layers={EDITOR_LAYER} renderOrder={2}>
<circleGeometry args={[0.06, 32]} />
<meshBasicMaterial
color={color}
depthTest={false}
depthWrite={false}
opacity={0.9}
transparent
/>
</mesh>
{/* Center dot — at the ground unless the placement point is elevated */}
{!dotAtTip && (
<mesh layers={EDITOR_LAYER} renderOrder={2}>
<circleGeometry args={[0.06, 32]} />
<meshBasicMaterial
color={color}
depthTest={false}
depthWrite={false}
opacity={0.9}
transparent
/>
</mesh>
)}
{/* Outer ring / glow */}
<mesh layers={EDITOR_LAYER} renderOrder={2}>
@@ -60,7 +80,7 @@ export const CursorSphere = forwardRef<Group, CursorSphereProps>(function Cursor
color={color}
depthTest={false}
depthWrite={false}
opacity={0.25}
opacity={dotAtTip ? 0.2 : 0.25}
transparent
/>
</mesh>
@@ -80,6 +100,15 @@ export const CursorSphere = forwardRef<Group, CursorSphereProps>(function Cursor
</mesh>
)}
{/* Bright marker dot at the tip of the line — the actual placement
point, riding at the cursor while the line drops to the floor. */}
{dotAtTip && height > 0 && (
<mesh layers={EDITOR_LAYER} position={[0, height, 0]} renderOrder={2}>
<sphereGeometry args={[0.08, 20, 14]} />
<meshBasicMaterial color={color} depthTest={false} depthWrite={false} />
</mesh>
)}
{/* Tool Icon Tooltip at the top of the line */}
{isVisible && showTooltip && (activeToolConfig || tooltipContent) && (
<Html
@@ -245,10 +245,10 @@ export function EditorCommands() {
label: 'Wall Mode',
group: 'Viewer Controls',
icon: <Layers className="h-4 w-4" />,
keywords: ['wall', 'cutaway', 'up', 'down', 'view'],
keywords: ['wall', 'cutaway', 'up', 'down', 'translucent', 'view'],
badge: () => {
const mode = useViewer.getState().wallMode
return { cutaway: 'Cutaway', up: 'Up', down: 'Down' }[mode]
return { cutaway: 'Cutaway', up: 'Up', down: 'Down', translucent: 'Translucent' }[mode]
},
navigate: true,
execute: () => navigateTo('wall-mode'),
@@ -244,10 +244,11 @@ export function CommandPalette({ emptyAction }: { emptyAction?: CommandPaletteEm
setOpen(false)
}
const wallModeLabel: Record<'cutaway' | 'up' | 'down', string> = {
const wallModeLabel: Record<'cutaway' | 'up' | 'down' | 'translucent', string> = {
cutaway: 'Cutaway',
up: 'Up',
down: 'Down',
translucent: 'Translucent',
}
const levelModeLabel: Record<'manual' | 'stacked' | 'exploded' | 'solo', string> = {
manual: 'Manual',
@@ -373,7 +374,7 @@ export function CommandPalette({ emptyAction }: { emptyAction?: CommandPaletteEm
{/* ── Wall Mode sub-page ────────────────────────────────────── */}
{page === 'wall-mode' && (
<Command.Group heading="Wall Mode">
{(['cutaway', 'up', 'down'] as const).map((mode) => (
{(['cutaway', 'up', 'down', 'translucent'] as const).map((mode) => (
<OptionItem
isActive={wallMode === mode}
key={mode}
@@ -72,17 +72,23 @@ export function HelperManager() {
.filter((node): node is AnyNode => node !== undefined),
),
)
const selectModeHints = useMemo(
() =>
resolveSelectModeHelpHints({
selectedCount: selectedNodes.length,
hasMovableSelection: selectedNodes.some((node) => canDirectMoveNode(node)),
hasRotatableSelection: selectedNodes.some((node) => canDirectRotateNode(node)),
commandPressed: modifiers.command,
shiftPressed: modifiers.shift,
}),
[modifiers.command, modifiers.shift, selectedNodes],
)
const selectModeHints = useMemo(() => {
const single = selectedNodes.length === 1 ? selectedNodes[0] : null
const mepSelection =
single?.type === 'duct-segment' || single?.type === 'pipe-segment'
? 'run'
: single?.type === 'duct-fitting' || single?.type === 'pipe-fitting'
? 'fitting'
: null
return resolveSelectModeHelpHints({
selectedCount: selectedNodes.length,
hasMovableSelection: selectedNodes.some((node) => canDirectMoveNode(node)),
hasRotatableSelection: selectedNodes.some((node) => canDirectRotateNode(node)),
commandPressed: modifiers.command,
shiftPressed: modifiers.shift,
mepSelection,
})
}, [modifiers.command, modifiers.shift, selectedNodes])
// Helpers are keyboard-driven hints (Esc, R, etc.) — irrelevant on touch.
if (isMobile) return null
@@ -25,6 +25,7 @@ import {
Check,
ChevronRight,
Diamond,
Footprints,
Layers,
Palette,
PenLine,
@@ -32,8 +33,10 @@ import {
Square,
} from 'lucide-react'
import Link from 'next/link'
import { flushSync } from 'react-dom'
import { useShallow } from 'zustand/react/shallow'
import { cn } from '../lib/utils'
import useEditor from '../store/use-editor'
import { ActionButton } from './ui/action-menu/action-button'
import {
DropdownMenu,
@@ -51,6 +54,24 @@ type ProjectOwner = {
image: string | null
}
function requestWalkthroughPointerLock() {
const canvas = document.querySelector<HTMLCanvasElement>('[data-pascal-viewer-3d] canvas')
if (!canvas) return
if (!canvas.hasAttribute('tabindex')) {
canvas.tabIndex = -1
}
canvas.focus({ preventScroll: true })
if (document.pointerLockElement === canvas) return
try {
canvas.requestPointerLock?.()
} catch {
return
}
}
const levelModeLabels: Record<'stacked' | 'exploded' | 'solo', string> = {
stacked: 'Stacked',
exploded: 'Exploded',
@@ -83,6 +104,12 @@ const wallModeConfig = {
),
label: 'Low',
},
translucent: {
icon: (props: any) => (
<img alt="Translucent" height={28} src="/icons/wall.png" width={28} {...props} />
),
label: 'Translucent',
},
}
const SHADING_OPTIONS = [
@@ -580,7 +607,12 @@ export const ViewerOverlay = ({
}
label={`Walls: ${wallModeConfig[wallMode as keyof typeof wallModeConfig].label}`}
onClick={() => {
const modes: ('cutaway' | 'up' | 'down')[] = ['cutaway', 'up', 'down']
const modes: ('cutaway' | 'up' | 'down' | 'translucent')[] = [
'cutaway',
'up',
'down',
'translucent',
]
const nextIndex = (modes.indexOf(wallMode as any) + 1) % modes.length
useViewer.getState().setWallMode(modes[nextIndex] ?? 'cutaway')
}}
@@ -641,6 +673,23 @@ export const ViewerOverlay = ({
src="/icons/topview.webp"
/>
</ActionButton>
<div className="mx-1 h-5 w-px bg-border/40" />
{/* First-person walkthrough */}
<ActionButton
className="hover:bg-white/5 hover:text-emerald-400"
label="Walkthrough"
onClick={() => {
flushSync(() => useEditor.getState().setFirstPersonMode(true))
requestWalkthroughPointerLock()
}}
size="icon"
tooltipSide="top"
variant="ghost"
>
<Footprints className="h-6 w-6" />
</ActionButton>
</div>
</TooltipProvider>
</div>
+21 -1
View File
@@ -40,7 +40,27 @@ export {
formatMeasurement,
MeasurementPill,
} from './components/editor/measurement-pill'
export { NodeArrowHandles } from './components/editor/node-arrow-handles'
// In-world arrow handle primitives (chevron geometry, invisible hit area,
// shared material, palette + scale constants). Re-exported so kind-owned
// 3D selection affordances in `@pascal-app/nodes` (duct side-move / height /
// extend arrows) reuse the same UI family as the wall / fence side handles.
export {
ARROW_COLOR,
ARROW_HOVER_COLOR,
ARROW_SCALE,
createArrowHandleGeometry,
createArrowHitAreaGeometry,
HandleArrow,
type HandleArrowInputShape,
type HandleArrowPlacement,
type HandleArrowProps,
InvisibleHandleHitArea,
NO_RAYCAST,
NodeArrowHandles,
swallowNextClick,
useArrowMaterial,
useInvisibleHitAreaMaterial,
} from './components/editor/node-arrow-handles'
export {
type SnapshotCameraData,
ThumbnailGenerator,
@@ -10,12 +10,19 @@ export type SelectModeHelpContext = {
hasRotatableSelection: boolean
commandPressed: boolean
shiftPressed: boolean
// When a single MEP node is selected its in-world handle rig (click a dot to
// reveal move arrows) is the real editing path, so the panel leads with the
// handle-specific hints instead of just the generic Cmd-drag tips.
mepSelection?: 'run' | 'fitting' | null
}
const COMMAND_KEY = 'Cmd/Ctrl'
const LEFT_CLICK = 'Left click'
const RIGHT_CLICK = 'Right click'
const SHIFT_KEY = 'Shift'
const CLICK = 'Click'
const ALT_KEY = 'Alt'
const ROTATE_KEYS = 'R / T'
export function resolveSelectModeHelpHints({
selectedCount,
@@ -23,6 +30,7 @@ export function resolveSelectModeHelpHints({
hasRotatableSelection,
commandPressed,
shiftPressed,
mepSelection = null,
}: SelectModeHelpContext): ContextualShortcutHint[] {
const hints: ContextualShortcutHint[] = []
@@ -37,6 +45,20 @@ export function resolveSelectModeHelpHints({
return hints
}
// MEP handle workflow — duct/pipe runs and fittings are edited through the
// in-world arrow rig that a click on the handle dot reveals, so surface those
// hints first. A run endpoint's side / up-down arrows swing the run and Alt
// detaches the joint mid-drag; a fitting's cluster adds rotate arcs, with
// R / T (and Alt to switch axis) for keyboard rotation.
if (mepSelection === 'run') {
hints.push({ keys: [CLICK], label: 'Click a handle dot to show move arrows' })
hints.push({ keys: [ALT_KEY], label: 'Detach the joint while dragging an arrow' })
} else if (mepSelection === 'fitting') {
hints.push({ keys: [CLICK], label: 'Click the handle dot to show move + rotate handles' })
hints.push({ keys: [ROTATE_KEYS], label: 'Rotate ±45°' })
hints.push({ keys: [ALT_KEY], label: 'Switch the rotation axis (Y → X → Z)' })
}
if (commandPressed) {
if (hasMovableSelection) {
hints.push({
+2
View File
@@ -10,6 +10,7 @@ type SFXEvents = {
'sfx:item-pick': undefined
'sfx:item-place': undefined
'sfx:item-rotate': undefined
'sfx:resize': undefined
'sfx:structure-build-start': undefined
'sfx:structure-build': undefined
'sfx:structure-delete': undefined
@@ -40,6 +41,7 @@ export function initSFXBus() {
sfxEmitter.on('sfx:item-pick', () => playSFX('itemPick'))
sfxEmitter.on('sfx:item-place', () => playSFX('itemPlace'))
sfxEmitter.on('sfx:item-rotate', () => playSFX('itemRotate'))
sfxEmitter.on('sfx:resize', () => playSFX('resize'))
sfxEmitter.on('sfx:structure-build-start', () => playSFX('structureBuildStart'))
sfxEmitter.on('sfx:structure-build', () => playSFX('structureBuildEnd'))
sfxEmitter.on('sfx:structure-delete', () => playSFX('structureDelete'))
+10
View File
@@ -59,6 +59,16 @@ export const SFX: Record<string, SFXConfig> = {
volumeRange: [0.92, 1.0],
panJitter: 0.15,
},
// Ticks as a resize handle is dragged across snap steps. Fires in rapid
// succession, so it mirrors gridSnap: three variations cycled round-robin
// with pitch/pan jitter and a gap so the run reads as texture, not a tone.
resize: {
src: ['/audios/sfx/resize_0.mp3', '/audios/sfx/resize_1.mp3', '/audios/sfx/resize_2.mp3'],
rateRange: [0.98, 1.02],
volumeRange: [0.26, 0.34],
panJitter: 0.15,
minIntervalMs: 80,
},
// Fired when a structure draft begins (first click of a wall/slab/etc).
structureBuildStart: {
src: '/audios/sfx/structure_build_start.mp3',