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:
@@ -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>
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user