feat(editor): preset-system polish — paint panel, slim action bar, icon rail (#354)
* feat(editor): preset-system polish — paint panel, slim action bar, icon rail - Export `MaterialPaintPanel` so embedders host the paint material picker in their own panel (community docks it in the Build sidebar) instead of the bottom action bar. - ActionMenu: drop the build / material-paint / furnish modes and the structure-tools palette row + paint tray (the host's Build sidebar owns building now). Reduce `structure-tools` to the shared `tools` lookup still used by cursor/floorplan indicators; remove the orphaned `useContextualTools`. - MaterialPicker: swatches wrap into a fluid `auto-fill` grid that fills width. - IconRail (tab-bar): bigger icons, grayscale-when-idle, Radix tooltip, w-14 rail; sync `RAIL_WIDTH` to 56. - Inspector footer: hand the host `footer` to kind-owned custom panels via `InspectorFooterContext` so the save button renders without per-kind wiring. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore: biome format pass Whole-repo formatter normalization (line wrap/unwrap only, no logic changes) surfaced by the format-on-edit hook against prior drift. Kept separate from the feature commit so the preset-system diff stays reviewable. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
891e578481
commit
8abfc94b99
@@ -102,8 +102,8 @@ export {
|
|||||||
} from './store/use-interactive'
|
} from './store/use-interactive'
|
||||||
export {
|
export {
|
||||||
default as useLiveNodeOverrides,
|
default as useLiveNodeOverrides,
|
||||||
type LiveNodeOverrides,
|
|
||||||
getEffectiveNode,
|
getEffectiveNode,
|
||||||
|
type LiveNodeOverrides,
|
||||||
} from './store/use-live-node-overrides'
|
} from './store/use-live-node-overrides'
|
||||||
export { default as useLiveTransforms, type LiveTransform } from './store/use-live-transforms'
|
export { default as useLiveTransforms, type LiveTransform } from './store/use-live-transforms'
|
||||||
export { clearSceneHistory, default as useScene } from './store/use-scene'
|
export { clearSceneHistory, default as useScene } from './store/use-scene'
|
||||||
|
|||||||
@@ -189,11 +189,7 @@ export type EndpointMoveHandle<N> = {
|
|||||||
endpoint: 'start' | 'end'
|
endpoint: 'start' | 'end'
|
||||||
placement: HandlePlacement<N>
|
placement: HandlePlacement<N>
|
||||||
/** Called with the world-space hit on the ground plane. */
|
/** Called with the world-space hit on the ground plane. */
|
||||||
apply: (
|
apply: (node: N, worldPoint: readonly [number, number, number], sceneApi: SceneApi) => Partial<N>
|
||||||
node: N,
|
|
||||||
worldPoint: readonly [number, number, number],
|
|
||||||
sceneApi: SceneApi,
|
|
||||||
) => Partial<N>
|
|
||||||
portal?: HandlePortal
|
portal?: HandlePortal
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -100,9 +100,7 @@ export function FloorplanRegistryActionMenu() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const el = sceneEl.querySelector(
|
const el = sceneEl.querySelector(`[data-node-id="${selectedId}"]`) as SVGGElement | null
|
||||||
`[data-node-id="${selectedId}"]`,
|
|
||||||
) as SVGGElement | null
|
|
||||||
if (el) {
|
if (el) {
|
||||||
const rect = el.getBoundingClientRect()
|
const rect = el.getBoundingClientRect()
|
||||||
setPosition({ left: rect.left + rect.width / 2, top: rect.top })
|
setPosition({ left: rect.left + rect.width / 2, top: rect.top })
|
||||||
|
|||||||
@@ -169,8 +169,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
|||||||
(editorPhase === 'structure' &&
|
(editorPhase === 'structure' &&
|
||||||
editorMode === 'build' &&
|
editorMode === 'build' &&
|
||||||
(editorTool === 'door' || editorTool === 'window')) ||
|
(editorTool === 'door' || editorTool === 'window')) ||
|
||||||
(movingNode != null &&
|
(movingNode != null && !!nodeRegistry.get(movingNode.type)?.capabilities?.wallOpeningPlacement)
|
||||||
!!nodeRegistry.get(movingNode.type)?.capabilities?.wallOpeningPlacement)
|
|
||||||
// Subscribe to the live-transforms map ref so the layer re-renders
|
// Subscribe to the live-transforms map ref so the layer re-renders
|
||||||
// whenever a 3D mover publishes a per-frame position (see
|
// whenever a 3D mover publishes a per-frame position (see
|
||||||
// `usePlacementCoordinator`). Without this the 2D floor plan only
|
// `usePlacementCoordinator`). Without this the 2D floor plan only
|
||||||
@@ -376,8 +375,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
|||||||
const hovered = hoveredId === cid
|
const hovered = hoveredId === cid
|
||||||
const moving = movingNode?.id === cid
|
const moving = movingNode?.id === cid
|
||||||
const ctx: GeometryContext = {
|
const ctx: GeometryContext = {
|
||||||
resolve: <N = AnyNode>(rid: AnyNodeId): N | undefined =>
|
resolve: <N = AnyNode>(rid: AnyNodeId): N | undefined => nodes[rid] as N | undefined,
|
||||||
nodes[rid] as N | undefined,
|
|
||||||
children: [],
|
children: [],
|
||||||
siblings: [],
|
siblings: [],
|
||||||
parent: activeLevelNode,
|
parent: activeLevelNode,
|
||||||
@@ -391,9 +389,10 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
|||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
}
|
}
|
||||||
const geometry = (
|
const geometry = (builder as (n: AnyNode, c: GeometryContext) => FloorplanGeometry | null)(
|
||||||
builder as (n: AnyNode, c: GeometryContext) => FloorplanGeometry | null
|
node,
|
||||||
)(node, ctx)
|
ctx,
|
||||||
|
)
|
||||||
if (geometry) {
|
if (geometry) {
|
||||||
const { base, overlay } = splitFloorplanOverlay(geometry)
|
const { base, overlay } = splitFloorplanOverlay(geometry)
|
||||||
out.push({ id: cid, node, base, overlay, selected, highlighted })
|
out.push({ id: cid, node, base, overlay, selected, highlighted })
|
||||||
@@ -1101,11 +1100,7 @@ function InteractiveGeometry({
|
|||||||
fill="transparent"
|
fill="transparent"
|
||||||
onPointerDown={(e) => {
|
onPointerDown={(e) => {
|
||||||
if (affordance) {
|
if (affordance) {
|
||||||
onHandlePointerDown(
|
onHandlePointerDown(affordance, payload, e as ReactPointerEvent<SVGGElement>)
|
||||||
affordance,
|
|
||||||
payload,
|
|
||||||
e as ReactPointerEvent<SVGGElement>,
|
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
onMoveHandlePointerDown(e as ReactPointerEvent<SVGGElement>)
|
onMoveHandlePointerDown(e as ReactPointerEvent<SVGGElement>)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ import { EditorLayoutMobile } from './editor-layout-mobile'
|
|||||||
const SIDEBAR_MIN_WIDTH = 300
|
const SIDEBAR_MIN_WIDTH = 300
|
||||||
const SIDEBAR_MAX_WIDTH = 800
|
const SIDEBAR_MAX_WIDTH = 800
|
||||||
const SIDEBAR_COLLAPSE_THRESHOLD = 220
|
const SIDEBAR_COLLAPSE_THRESHOLD = 220
|
||||||
// Matches the `w-12` rail in <IconRail>; the resize math is relative to it.
|
// Matches the `w-14` rail in <IconRail>; the resize math is relative to it.
|
||||||
const RAIL_WIDTH = 48
|
const RAIL_WIDTH = 56
|
||||||
|
|
||||||
// ── Left column: resizable panel with tab bar ────────────────────────────────
|
// ── Left column: resizable panel with tab bar ────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -100,9 +100,7 @@ const MENU_Y_OFFSETS: Record<string, number> = {
|
|||||||
function getMenuYOffset(node: AnyNode | null): number {
|
function getMenuYOffset(node: AnyNode | null): number {
|
||||||
if (!node) return MENU_Y_OFFSET_DEFAULT + EXTRA_MENU_LIFT
|
if (!node) return MENU_Y_OFFSET_DEFAULT + EXTRA_MENU_LIFT
|
||||||
if (node.type === 'stair-segment') {
|
if (node.type === 'stair-segment') {
|
||||||
return (
|
return (MENU_Y_OFFSETS[`stair-${node.segmentType}`] ?? MENU_Y_OFFSET_DEFAULT) + EXTRA_MENU_LIFT
|
||||||
(MENU_Y_OFFSETS[`stair-${node.segmentType}`] ?? MENU_Y_OFFSET_DEFAULT) + EXTRA_MENU_LIFT
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
return (MENU_Y_OFFSETS[node.type] ?? MENU_Y_OFFSET_DEFAULT) + EXTRA_MENU_LIFT
|
return (MENU_Y_OFFSETS[node.type] ?? MENU_Y_OFFSET_DEFAULT) + EXTRA_MENU_LIFT
|
||||||
}
|
}
|
||||||
@@ -181,7 +179,6 @@ export function FloatingActionMenu() {
|
|||||||
// in-world chrome (height-resize arrows, measurement labels).
|
// in-world chrome (height-resize arrows, measurement labels).
|
||||||
groupRef.current.position.set(center.x, box.max.y + getMenuYOffset(node), center.z)
|
groupRef.current.position.set(center.x, box.max.y + getMenuYOffset(node), center.z)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -5045,7 +5045,9 @@ export function FloorplanPanel() {
|
|||||||
// space. Length renders at the segment midpoint; angle arcs sit at
|
// space. Length renders at the segment midpoint; angle arcs sit at
|
||||||
// each endpoint that meets an existing wall.
|
// each endpoint that meets an existing wall.
|
||||||
const draftWallMeasurement = useMemo(() => {
|
const draftWallMeasurement = useMemo(() => {
|
||||||
if (!(isWallBuildActive && draftStart && draftEnd && isSegmentLongEnough(draftStart, draftEnd))) {
|
if (
|
||||||
|
!(isWallBuildActive && draftStart && draftEnd && isSegmentLongEnough(draftStart, draftEnd))
|
||||||
|
) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -237,8 +237,7 @@ export function NodeArrowHandles() {
|
|||||||
rawNode ? s.overrides.get(rawNode.id) : undefined,
|
rawNode ? s.overrides.get(rawNode.id) : undefined,
|
||||||
)
|
)
|
||||||
const node = useMemo<AnyNode | null>(
|
const node = useMemo<AnyNode | null>(
|
||||||
() =>
|
() => (rawNode && liveOverride ? ({ ...rawNode, ...liveOverride } as AnyNode) : rawNode),
|
||||||
rawNode && liveOverride ? ({ ...rawNode, ...liveOverride } as AnyNode) : rawNode,
|
|
||||||
[rawNode, liveOverride],
|
[rawNode, liveOverride],
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -251,10 +250,7 @@ export function NodeArrowHandles() {
|
|||||||
}, [node, def])
|
}, [node, def])
|
||||||
|
|
||||||
const shouldRender =
|
const shouldRender =
|
||||||
Boolean(node && descriptors?.length) &&
|
Boolean(node && descriptors?.length) && !isFloorplanHovered && mode !== 'delete' && !movingNode
|
||||||
!isFloorplanHovered &&
|
|
||||||
mode !== 'delete' &&
|
|
||||||
!movingNode
|
|
||||||
|
|
||||||
if (!shouldRender || !node || !descriptors) return null
|
if (!shouldRender || !node || !descriptors) return null
|
||||||
return <NodeArrowHandlesForNode descriptors={descriptors} node={node} />
|
return <NodeArrowHandlesForNode descriptors={descriptors} node={node} />
|
||||||
@@ -436,7 +432,10 @@ function pickCursor(descriptor: LinearResizeHandle<AnyNode> | RadialResizeHandle
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resolveBound(
|
function resolveBound(
|
||||||
bound: number | ((node: AnyNode, sceneApi: ReturnType<typeof createSceneApi>) => number) | undefined,
|
bound:
|
||||||
|
| number
|
||||||
|
| ((node: AnyNode, sceneApi: ReturnType<typeof createSceneApi>) => number)
|
||||||
|
| undefined,
|
||||||
fallback: number,
|
fallback: number,
|
||||||
node: AnyNode,
|
node: AnyNode,
|
||||||
sceneApi: ReturnType<typeof createSceneApi>,
|
sceneApi: ReturnType<typeof createSceneApi>,
|
||||||
@@ -572,10 +571,7 @@ function LinearArrow({
|
|||||||
? intersectionLocal.y
|
? intersectionLocal.y
|
||||||
: intersectionLocal.z
|
: intersectionLocal.z
|
||||||
const delta = currentPointer - initialPointer
|
const delta = currentPointer - initialPointer
|
||||||
const next = Math.min(
|
const next = Math.min(maxBound, Math.max(minBound, initialValue + delta * factor))
|
||||||
maxBound,
|
|
||||||
Math.max(minBound, initialValue + delta * factor),
|
|
||||||
)
|
|
||||||
// apply sees the node-at-drag-start so it can compute anchors from
|
// apply sees the node-at-drag-start so it can compute anchors from
|
||||||
// pre-drag geometry (door-width re-centers on the opposite edge).
|
// pre-drag geometry (door-width re-centers on the opposite edge).
|
||||||
const patch = descriptor.apply(initialNode as never, next, sceneApi)
|
const patch = descriptor.apply(initialNode as never, next, sceneApi)
|
||||||
@@ -928,8 +924,7 @@ function TapActionArrow({
|
|||||||
const position = descriptor.placement.position(node, placementSceneApi)
|
const position = descriptor.placement.position(node, placementSceneApi)
|
||||||
const rotationY = descriptor.placement.rotationY?.(node, placementSceneApi) ?? 0
|
const rotationY = descriptor.placement.rotationY?.(node, placementSceneApi) ?? 0
|
||||||
const shape = descriptor.shape ?? 'arrow'
|
const shape = descriptor.shape ?? 'arrow'
|
||||||
const cursor: Cursor =
|
const cursor: Cursor = descriptor.cursor ?? (shape === 'corner-picker' ? 'move' : 'ew-resize')
|
||||||
descriptor.cursor ?? (shape === 'corner-picker' ? 'move' : 'ew-resize')
|
|
||||||
|
|
||||||
const onActivate = (event: ThreeEvent<PointerEvent>) => {
|
const onActivate = (event: ThreeEvent<PointerEvent>) => {
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
|
|||||||
@@ -658,7 +658,6 @@ function FenceMoveArrowHandle({ fence, handle }: { fence: FenceNode; handle: Wal
|
|||||||
frustumCulled={false}
|
frustumCulled={false}
|
||||||
geometry={arrowGeometry}
|
geometry={arrowGeometry}
|
||||||
material={arrowMaterial}
|
material={arrowMaterial}
|
||||||
|
|
||||||
onPointerDown={activateFenceMove}
|
onPointerDown={activateFenceMove}
|
||||||
onPointerEnter={(event) => {
|
onPointerEnter={(event) => {
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
|
|||||||
@@ -514,9 +514,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
|||||||
position={[x!, editY + height / 2, z!]}
|
position={[x!, editY + height / 2, z!]}
|
||||||
>
|
>
|
||||||
<cylinderGeometry args={[radius, radius, height, 16]} />
|
<cylinderGeometry args={[radius, radius, height, 16]} />
|
||||||
<meshBasicMaterial
|
<meshBasicMaterial color={isDragging ? '#22c55e' : isHovered ? '#60a5fa' : '#3b82f6'} />
|
||||||
color={isDragging ? '#22c55e' : isHovered ? '#60a5fa' : '#3b82f6'}
|
|
||||||
/>
|
|
||||||
</mesh>
|
</mesh>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -9,15 +9,7 @@ import { cn } from './../../../lib/utils'
|
|||||||
import useEditor from './../../../store/use-editor'
|
import useEditor from './../../../store/use-editor'
|
||||||
import { ActionButton } from './action-button'
|
import { ActionButton } from './action-button'
|
||||||
|
|
||||||
type ControlId =
|
type ControlId = 'select' | 'box-select' | 'site-edit' | 'zone' | 'delete'
|
||||||
| 'select'
|
|
||||||
| 'box-select'
|
|
||||||
| 'site-edit'
|
|
||||||
| 'build'
|
|
||||||
| 'material-paint'
|
|
||||||
| 'furnish'
|
|
||||||
| 'zone'
|
|
||||||
| 'delete'
|
|
||||||
|
|
||||||
type ControlConfig = {
|
type ControlConfig = {
|
||||||
id: ControlId
|
id: ControlId
|
||||||
@@ -54,30 +46,6 @@ const controls: ControlConfig[] = [
|
|||||||
color: 'hover:bg-white/5',
|
color: 'hover:bg-white/5',
|
||||||
activeColor: 'bg-white/10 hover:bg-white/10',
|
activeColor: 'bg-white/10 hover:bg-white/10',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: 'build',
|
|
||||||
imageSrc: '/icons/build.png',
|
|
||||||
label: 'Build',
|
|
||||||
shortcut: 'B',
|
|
||||||
color: 'hover:bg-green-500/20 hover:text-green-400',
|
|
||||||
activeColor: 'bg-green-500/20 text-green-400',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'material-paint',
|
|
||||||
imageSrc: '/icons/paint.png',
|
|
||||||
label: 'Material Paint',
|
|
||||||
shortcut: 'P',
|
|
||||||
color: 'hover:bg-amber-500/20 hover:text-amber-400',
|
|
||||||
activeColor: 'bg-amber-500/20 text-amber-400',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'furnish',
|
|
||||||
imageSrc: '/icons/couch.png',
|
|
||||||
label: 'Furnish',
|
|
||||||
shortcut: 'F',
|
|
||||||
color: 'hover:bg-green-500/20 hover:text-green-400',
|
|
||||||
activeColor: 'bg-green-500/20 text-green-400',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: 'zone',
|
id: 'zone',
|
||||||
imageSrc: '/icons/zone.png',
|
imageSrc: '/icons/zone.png',
|
||||||
@@ -104,9 +72,6 @@ export function ControlModes() {
|
|||||||
const setPhase = useEditor((state) => state.setPhase)
|
const setPhase = useEditor((state) => state.setPhase)
|
||||||
const setStructureLayer = useEditor((state) => state.setStructureLayer)
|
const setStructureLayer = useEditor((state) => state.setStructureLayer)
|
||||||
const setSelectionTool = useEditor((state) => state.setFloorplanSelectionTool)
|
const setSelectionTool = useEditor((state) => state.setFloorplanSelectionTool)
|
||||||
const primeMaterialPaintFromSelection = useEditor(
|
|
||||||
(state) => state.primeMaterialPaintFromSelection,
|
|
||||||
)
|
|
||||||
const levelId = useViewer((s) => s.selection.levelId)
|
const levelId = useViewer((s) => s.selection.levelId)
|
||||||
|
|
||||||
// Only subscribe to the primitive `level` number — when walls are added to
|
// Only subscribe to the primitive `level` number — when walls are added to
|
||||||
@@ -129,10 +94,6 @@ export function ControlModes() {
|
|||||||
if (id === 'select') return mode === 'select' && selectionTool === 'click'
|
if (id === 'select') return mode === 'select' && selectionTool === 'click'
|
||||||
if (id === 'box-select') return mode === 'select' && selectionTool === 'marquee'
|
if (id === 'box-select') return mode === 'select' && selectionTool === 'marquee'
|
||||||
if (id === 'site-edit') return false
|
if (id === 'site-edit') return false
|
||||||
if (id === 'build')
|
|
||||||
return mode === 'build' && phase === 'structure' && structureLayer === 'elements'
|
|
||||||
if (id === 'material-paint') return mode === 'material-paint'
|
|
||||||
if (id === 'furnish') return mode === 'build' && phase === 'furnish'
|
|
||||||
if (id === 'zone')
|
if (id === 'zone')
|
||||||
return mode === 'build' && phase === 'structure' && structureLayer === 'zones'
|
return mode === 'build' && phase === 'structure' && structureLayer === 'zones'
|
||||||
return mode === id
|
return mode === id
|
||||||
@@ -168,33 +129,6 @@ export function ControlModes() {
|
|||||||
} else if (id === 'box-select') {
|
} else if (id === 'box-select') {
|
||||||
setMode('select')
|
setMode('select')
|
||||||
setSelectionTool('marquee')
|
setSelectionTool('marquee')
|
||||||
} else if (id === 'build') {
|
|
||||||
// Toggle: if already in structure build, go back to select
|
|
||||||
if (getIsActive('build')) {
|
|
||||||
setMode('select')
|
|
||||||
} else {
|
|
||||||
setPhase('structure')
|
|
||||||
setStructureLayer('elements')
|
|
||||||
setMode('build')
|
|
||||||
}
|
|
||||||
} else if (id === 'material-paint') {
|
|
||||||
if (getIsActive('material-paint')) {
|
|
||||||
setMode('select')
|
|
||||||
} else {
|
|
||||||
primeMaterialPaintFromSelection()
|
|
||||||
setPhase('structure')
|
|
||||||
setStructureLayer('elements')
|
|
||||||
setMode('material-paint')
|
|
||||||
}
|
|
||||||
} else if (id === 'furnish') {
|
|
||||||
if (getIsActive('furnish')) {
|
|
||||||
setMode('select')
|
|
||||||
} else {
|
|
||||||
setPhase('furnish')
|
|
||||||
setMode('build')
|
|
||||||
// Auto-switch sidebar to the items panel so the user can pick furniture
|
|
||||||
useEditor.getState().setActiveSidebarPanel('items')
|
|
||||||
}
|
|
||||||
} else if (id === 'zone') {
|
} else if (id === 'zone') {
|
||||||
if (getIsActive('zone')) {
|
if (getIsActive('zone')) {
|
||||||
setMode('select')
|
setMode('select')
|
||||||
|
|||||||
@@ -1,19 +1,14 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useScene } from '@pascal-app/core'
|
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { AnimatePresence, motion } from 'motion/react'
|
import { motion } from 'motion/react'
|
||||||
import { useEffect, useMemo } from 'react'
|
|
||||||
import { MaterialPicker } from './../../../components/ui/controls/material-picker'
|
|
||||||
import { TooltipProvider } from './../../../components/ui/primitives/tooltip'
|
import { TooltipProvider } from './../../../components/ui/primitives/tooltip'
|
||||||
import { useIsMobile } from './../../../hooks/use-mobile'
|
import { useIsMobile } from './../../../hooks/use-mobile'
|
||||||
import { useReducedMotion } from './../../../hooks/use-reduced-motion'
|
import { useReducedMotion } from './../../../hooks/use-reduced-motion'
|
||||||
import { resolvePaintTargetFromSelection } from './../../../lib/material-paint'
|
|
||||||
import { cn } from './../../../lib/utils'
|
import { cn } from './../../../lib/utils'
|
||||||
import useEditor from './../../../store/use-editor'
|
import useEditor from './../../../store/use-editor'
|
||||||
import { CameraActions } from './camera-actions'
|
import { CameraActions } from './camera-actions'
|
||||||
import { ControlModes } from './control-modes'
|
import { ControlModes } from './control-modes'
|
||||||
import { StructureTools } from './structure-tools'
|
|
||||||
import { GridSnapControl, SecondaryToggles } from './view-toggles'
|
import { GridSnapControl, SecondaryToggles } from './view-toggles'
|
||||||
|
|
||||||
// Mobile bottom offset matches the viewer's overlap behind the sheet's
|
// Mobile bottom offset matches the viewer's overlap behind the sheet's
|
||||||
@@ -21,47 +16,7 @@ import { GridSnapControl, SecondaryToggles } from './view-toggles'
|
|||||||
// just above that strip instead of inside it.
|
// just above that strip instead of inside it.
|
||||||
const MOBILE_BOTTOM_OFFSET = 24
|
const MOBILE_BOTTOM_OFFSET = 24
|
||||||
|
|
||||||
function PaintMaterialTray() {
|
|
||||||
const activePaintMaterial = useEditor((state) => state.activePaintMaterial)
|
|
||||||
const activePaintTarget = useEditor((state) => state.activePaintTarget)
|
|
||||||
const setActivePaintMaterial = useEditor((state) => state.setActivePaintMaterial)
|
|
||||||
const setActivePaintTarget = useEditor((state) => state.setActivePaintTarget)
|
|
||||||
const selectedIds = useViewer((state) => state.selection.selectedIds)
|
|
||||||
const nodes = useScene((state) => state.nodes)
|
|
||||||
const selectedId = selectedIds.length === 1 ? (selectedIds[0] ?? null) : null
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const selectedPaintTarget = resolvePaintTargetFromSelection({
|
|
||||||
nodes,
|
|
||||||
selectedId,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (selectedPaintTarget) {
|
|
||||||
setActivePaintTarget(selectedPaintTarget)
|
|
||||||
}
|
|
||||||
}, [nodes, selectedId, setActivePaintTarget])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="w-[42rem] max-w-[calc(100vw-2rem)]">
|
|
||||||
<MaterialPicker
|
|
||||||
onChange={(material) => {
|
|
||||||
setActivePaintMaterial({ material, sourceTarget: activePaintTarget })
|
|
||||||
}}
|
|
||||||
onSelectMaterialPreset={(materialPreset) => {
|
|
||||||
setActivePaintMaterial({ materialPreset, sourceTarget: activePaintTarget })
|
|
||||||
}}
|
|
||||||
selectedMaterialPreset={activePaintMaterial?.materialPreset}
|
|
||||||
value={activePaintMaterial?.material}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ActionMenu({ className }: { className?: string }) {
|
export function ActionMenu({ className }: { className?: string }) {
|
||||||
const phase = useEditor((state) => state.phase)
|
|
||||||
const mode = useEditor((state) => state.mode)
|
|
||||||
const tool = useEditor((state) => state.tool)
|
|
||||||
const catalogCategory = useEditor((state) => state.catalogCategory)
|
|
||||||
const isMobile = useIsMobile()
|
const isMobile = useIsMobile()
|
||||||
const hasSelectionOnMobile = useViewer((s) => isMobile && s.selection.selectedIds.length > 0)
|
const hasSelectionOnMobile = useViewer((s) => isMobile && s.selection.selectedIds.length > 0)
|
||||||
const hasReferenceOnMobile = useEditor((s) => isMobile && Boolean(s.selectedReferenceId))
|
const hasReferenceOnMobile = useEditor((s) => isMobile && Boolean(s.selectedReferenceId))
|
||||||
@@ -70,7 +25,6 @@ export function ActionMenu({ className }: { className?: string }) {
|
|||||||
(s) => isMobile && CONTEXTUAL_TABS.has(s.activeSidebarPanel),
|
(s) => isMobile && CONTEXTUAL_TABS.has(s.activeSidebarPanel),
|
||||||
)
|
)
|
||||||
const reducedMotion = useReducedMotion()
|
const reducedMotion = useReducedMotion()
|
||||||
const showPaintTray = useMemo(() => mode === 'material-paint', [mode])
|
|
||||||
|
|
||||||
// On mobile, defer the bottom rail to the selection bar when something
|
// On mobile, defer the bottom rail to the selection bar when something
|
||||||
// is selected — the contextual actions take priority over mode controls.
|
// is selected — the contextual actions take priority over mode controls.
|
||||||
@@ -97,72 +51,6 @@ export function ActionMenu({ className }: { className?: string }) {
|
|||||||
style={isMobile ? { bottom: MOBILE_BOTTOM_OFFSET } : undefined}
|
style={isMobile ? { bottom: MOBILE_BOTTOM_OFFSET } : undefined}
|
||||||
transition={transition}
|
transition={transition}
|
||||||
>
|
>
|
||||||
{/* Structure Tools Row - Animated */}
|
|
||||||
<AnimatePresence>
|
|
||||||
{phase === 'structure' && mode === 'build' && (
|
|
||||||
<motion.div
|
|
||||||
animate={{
|
|
||||||
opacity: 1,
|
|
||||||
maxHeight: 80,
|
|
||||||
paddingTop: 8,
|
|
||||||
paddingBottom: 8,
|
|
||||||
borderBottomWidth: 1,
|
|
||||||
}}
|
|
||||||
className={cn('max-h-20 overflow-hidden border-border border-b px-2 py-2')}
|
|
||||||
exit={{
|
|
||||||
opacity: 0,
|
|
||||||
maxHeight: 0,
|
|
||||||
paddingTop: 0,
|
|
||||||
paddingBottom: 0,
|
|
||||||
borderBottomWidth: 0,
|
|
||||||
}}
|
|
||||||
initial={{
|
|
||||||
opacity: 0,
|
|
||||||
maxHeight: 0,
|
|
||||||
paddingTop: 0,
|
|
||||||
paddingBottom: 0,
|
|
||||||
borderBottomWidth: 0,
|
|
||||||
}}
|
|
||||||
transition={transition}
|
|
||||||
>
|
|
||||||
<div className="w-max">
|
|
||||||
<StructureTools />
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
|
|
||||||
<AnimatePresence>
|
|
||||||
{showPaintTray && (
|
|
||||||
<motion.div
|
|
||||||
animate={{
|
|
||||||
opacity: 1,
|
|
||||||
maxHeight: 96,
|
|
||||||
paddingTop: 8,
|
|
||||||
paddingBottom: 8,
|
|
||||||
borderBottomWidth: 1,
|
|
||||||
}}
|
|
||||||
className={cn('overflow-hidden border-border border-b px-3')}
|
|
||||||
exit={{
|
|
||||||
opacity: 0,
|
|
||||||
maxHeight: 0,
|
|
||||||
paddingTop: 0,
|
|
||||||
paddingBottom: 0,
|
|
||||||
borderBottomWidth: 0,
|
|
||||||
}}
|
|
||||||
initial={{
|
|
||||||
opacity: 0,
|
|
||||||
maxHeight: 0,
|
|
||||||
paddingTop: 0,
|
|
||||||
paddingBottom: 0,
|
|
||||||
borderBottomWidth: 0,
|
|
||||||
}}
|
|
||||||
transition={transition}
|
|
||||||
>
|
|
||||||
<PaintMaterialTray />
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
{isMobile ? (
|
{isMobile ? (
|
||||||
<div className="flex flex-col items-stretch gap-0.5 px-2 py-1.5">
|
<div className="flex flex-col items-stretch gap-0.5 px-2 py-1.5">
|
||||||
{/* Row 1: control modes only */}
|
{/* Row 1: control modes only */}
|
||||||
|
|||||||
@@ -1,15 +1,4 @@
|
|||||||
'use client'
|
import type { CatalogCategory, StructureTool } from '../../../store/use-editor'
|
||||||
|
|
||||||
import NextImage from 'next/image'
|
|
||||||
import { useContextualTools } from '../../../hooks/use-contextual-tools'
|
|
||||||
|
|
||||||
import { cn } from '../../../lib/utils'
|
|
||||||
import useEditor, {
|
|
||||||
type CatalogCategory,
|
|
||||||
type StructureTool,
|
|
||||||
type Tool,
|
|
||||||
} from '../../../store/use-editor'
|
|
||||||
import { ActionButton } from './action-button'
|
|
||||||
|
|
||||||
export type ToolConfig = {
|
export type ToolConfig = {
|
||||||
id: StructureTool
|
id: StructureTool
|
||||||
@@ -18,6 +7,10 @@ export type ToolConfig = {
|
|||||||
catalogCategory?: CatalogCategory
|
catalogCategory?: CatalogCategory
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Shared structure-tool metadata (icons + labels). The build palette now lives
|
||||||
|
// in the community Build sidebar; this list survives only as the lookup table
|
||||||
|
// for cursor/floorplan indicators. Roof-mounted accessories are intentionally
|
||||||
|
// absent — they're placed from the roof inspector's "Add element" section.
|
||||||
export const tools: ToolConfig[] = [
|
export const tools: ToolConfig[] = [
|
||||||
{ id: 'wall', iconSrc: '/icons/wall.png', label: 'Wall' },
|
{ id: 'wall', iconSrc: '/icons/wall.png', label: 'Wall' },
|
||||||
{ id: 'door', iconSrc: '/icons/door.png', label: 'Door' },
|
{ id: 'door', iconSrc: '/icons/door.png', label: 'Door' },
|
||||||
@@ -27,85 +20,9 @@ export const tools: ToolConfig[] = [
|
|||||||
{ id: 'fence', iconSrc: '/icons/fence.png', label: 'Fence' },
|
{ id: 'fence', iconSrc: '/icons/fence.png', label: 'Fence' },
|
||||||
{ id: 'column', iconSrc: '/icons/column.png', label: 'Column' },
|
{ id: 'column', iconSrc: '/icons/column.png', label: 'Column' },
|
||||||
{ id: 'elevator', iconSrc: '/icons/elevator.png', label: 'Elevator' },
|
{ id: 'elevator', iconSrc: '/icons/elevator.png', label: 'Elevator' },
|
||||||
// { id: 'room', iconSrc: '/icons/room.png', label: 'Room' },
|
|
||||||
// { id: 'custom-room', iconSrc: '/icons/custom-room.png', label: 'Custom Room' },
|
|
||||||
{ id: 'slab', iconSrc: '/icons/floor.png', label: 'Slab' },
|
{ id: 'slab', iconSrc: '/icons/floor.png', label: 'Slab' },
|
||||||
{ id: 'ceiling', iconSrc: '/icons/ceiling.png', label: 'Ceiling' },
|
{ id: 'ceiling', iconSrc: '/icons/ceiling.png', label: 'Ceiling' },
|
||||||
{ id: 'zone', iconSrc: '/icons/zone.png', label: 'Zone' },
|
{ id: 'zone', iconSrc: '/icons/zone.png', label: 'Zone' },
|
||||||
{ id: 'spawn', iconSrc: '/icons/site.png', label: 'Spawn Point' },
|
{ id: 'spawn', iconSrc: '/icons/site.png', label: 'Spawn Point' },
|
||||||
{ id: 'shelf', iconSrc: '/icons/shelf.png', label: 'Shelf' },
|
{ id: 'shelf', iconSrc: '/icons/shelf.png', label: 'Shelf' },
|
||||||
// Roof-mounted accessories (box-vent / ridge-vent / chimney /
|
|
||||||
// solar-panel / skylight / dormer) are intentionally NOT in the top
|
|
||||||
// palette — they only make sense in the context of a selected roof.
|
|
||||||
// The roof inspector's "Add element" section is the entry point
|
|
||||||
// (`packages/nodes/src/roof/panel.tsx`), which activates the same
|
|
||||||
// registry-driven placement tools via `setTool(kind)`.
|
|
||||||
]
|
]
|
||||||
|
|
||||||
export function StructureTools() {
|
|
||||||
const activeTool = useEditor((state) => state.tool)
|
|
||||||
const catalogCategory = useEditor((state) => state.catalogCategory)
|
|
||||||
const structureLayer = useEditor((state) => state.structureLayer)
|
|
||||||
const setTool = useEditor((state) => state.setTool)
|
|
||||||
const setCatalogCategory = useEditor((state) => state.setCatalogCategory)
|
|
||||||
|
|
||||||
const contextualTools = useContextualTools()
|
|
||||||
|
|
||||||
// Filter tools based on structureLayer
|
|
||||||
const visibleTools =
|
|
||||||
structureLayer === 'zones'
|
|
||||||
? tools.filter((t) => t.id === 'zone')
|
|
||||||
: tools.filter((t) => t.id !== 'zone')
|
|
||||||
|
|
||||||
const hasActiveTool = visibleTools.some(
|
|
||||||
(t) =>
|
|
||||||
activeTool === t.id && (t.catalogCategory ? catalogCategory === t.catalogCategory : true),
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-1.5 px-1">
|
|
||||||
{visibleTools.map((tool, index) => {
|
|
||||||
// For item tools with catalog category, check both tool and category match
|
|
||||||
const isActive =
|
|
||||||
activeTool === tool.id &&
|
|
||||||
(tool.catalogCategory ? catalogCategory === tool.catalogCategory : true)
|
|
||||||
|
|
||||||
const isContextual = contextualTools.includes(tool.id)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ActionButton
|
|
||||||
className={cn(
|
|
||||||
'rounded-lg duration-300',
|
|
||||||
isActive
|
|
||||||
? 'z-10 scale-110 bg-black/40 hover:bg-black/40'
|
|
||||||
: 'scale-95 bg-transparent opacity-60 grayscale hover:bg-black/20 hover:opacity-100 hover:grayscale-0',
|
|
||||||
)}
|
|
||||||
key={`${tool.id}-${tool.catalogCategory ?? index}`}
|
|
||||||
label={tool.label}
|
|
||||||
onClick={() => {
|
|
||||||
if (!isActive) {
|
|
||||||
setTool(tool.id)
|
|
||||||
setCatalogCategory(tool.catalogCategory ?? null)
|
|
||||||
|
|
||||||
// Automatically switch to build mode if we select a tool
|
|
||||||
if (useEditor.getState().mode !== 'build') {
|
|
||||||
useEditor.getState().setMode('build')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
size="icon"
|
|
||||||
variant="ghost"
|
|
||||||
>
|
|
||||||
<NextImage
|
|
||||||
alt={tool.label}
|
|
||||||
className="size-full object-contain"
|
|
||||||
height={28}
|
|
||||||
src={tool.iconSrc}
|
|
||||||
width={28}
|
|
||||||
/>
|
|
||||||
</ActionButton>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useScene } from '@pascal-app/core'
|
||||||
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
|
import { useEffect } from 'react'
|
||||||
|
import { resolvePaintTargetFromSelection } from './../../../lib/material-paint'
|
||||||
|
import useEditor from './../../../store/use-editor'
|
||||||
|
import { MaterialPicker } from './material-picker'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Material picker for paint mode. Embedders render this wherever paint controls
|
||||||
|
* belong (the community editor places it in the Build sidebar while paint mode
|
||||||
|
* is active). It owns the paint-target/material wiring so the host only needs
|
||||||
|
* to mount it; it fills its container's width.
|
||||||
|
*/
|
||||||
|
export function MaterialPaintPanel() {
|
||||||
|
const activePaintMaterial = useEditor((state) => state.activePaintMaterial)
|
||||||
|
const activePaintTarget = useEditor((state) => state.activePaintTarget)
|
||||||
|
const setActivePaintMaterial = useEditor((state) => state.setActivePaintMaterial)
|
||||||
|
const setActivePaintTarget = useEditor((state) => state.setActivePaintTarget)
|
||||||
|
const selectedIds = useViewer((state) => state.selection.selectedIds)
|
||||||
|
const nodes = useScene((state) => state.nodes)
|
||||||
|
const selectedId = selectedIds.length === 1 ? (selectedIds[0] ?? null) : null
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const selectedPaintTarget = resolvePaintTargetFromSelection({ nodes, selectedId })
|
||||||
|
if (selectedPaintTarget) {
|
||||||
|
setActivePaintTarget(selectedPaintTarget)
|
||||||
|
}
|
||||||
|
}, [nodes, selectedId, setActivePaintTarget])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
<MaterialPicker
|
||||||
|
onChange={(material) => {
|
||||||
|
setActivePaintMaterial({ material, sourceTarget: activePaintTarget })
|
||||||
|
}}
|
||||||
|
onSelectMaterialPreset={(materialPreset) => {
|
||||||
|
setActivePaintMaterial({ materialPreset, sourceTarget: activePaintTarget })
|
||||||
|
}}
|
||||||
|
selectedMaterialPreset={activePaintMaterial?.materialPreset}
|
||||||
|
value={activePaintMaterial?.material}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -39,7 +39,6 @@ export function MaterialPicker({
|
|||||||
const [selectedCategory, setSelectedCategory] = useState<(typeof MATERIAL_CATEGORIES)[number]>(
|
const [selectedCategory, setSelectedCategory] = useState<(typeof MATERIAL_CATEGORIES)[number]>(
|
||||||
MATERIAL_CATEGORIES[0],
|
MATERIAL_CATEGORIES[0],
|
||||||
)
|
)
|
||||||
const catalogScrollRef = useRef<HTMLDivElement>(null)
|
|
||||||
const categoryScrollRef = useRef<HTMLDivElement>(null)
|
const categoryScrollRef = useRef<HTMLDivElement>(null)
|
||||||
const catalogItems =
|
const catalogItems =
|
||||||
selectedCategory === 'other'
|
selectedCategory === 'other'
|
||||||
@@ -73,27 +72,6 @@ export function MaterialPicker({
|
|||||||
onSelectMaterialPreset?.(toLibraryMaterialRef(materialId))
|
onSelectMaterialPreset?.(toLibraryMaterialRef(materialId))
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const container = catalogScrollRef.current
|
|
||||||
if (!container) return
|
|
||||||
|
|
||||||
const handleWheel = (event: WheelEvent) => {
|
|
||||||
const deltaX = event.deltaX
|
|
||||||
const deltaY = event.deltaY
|
|
||||||
const nextScrollLeft = container.scrollLeft + deltaX + deltaY
|
|
||||||
|
|
||||||
if (nextScrollLeft === container.scrollLeft) return
|
|
||||||
|
|
||||||
event.preventDefault()
|
|
||||||
container.scrollLeft = nextScrollLeft
|
|
||||||
}
|
|
||||||
|
|
||||||
container.addEventListener('wheel', handleWheel, { passive: false })
|
|
||||||
return () => {
|
|
||||||
container.removeEventListener('wheel', handleWheel)
|
|
||||||
}
|
|
||||||
}, [catalogItems.length, onChange, showCustom])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const container = categoryScrollRef.current
|
const container = categoryScrollRef.current
|
||||||
if (!container) return
|
if (!container) return
|
||||||
@@ -167,14 +145,12 @@ export function MaterialPicker({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className="w-full max-w-full overflow-x-auto overflow-y-hidden"
|
className="grid gap-1.5 pb-1"
|
||||||
ref={catalogScrollRef}
|
style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(72px, 1fr))' }}
|
||||||
style={{ msOverflowStyle: 'none', scrollbarWidth: 'none' }}
|
|
||||||
>
|
>
|
||||||
<div className="flex min-w-max gap-1.5 pb-1">
|
{catalogItems.map((item) => (
|
||||||
{catalogItems.map((item) => (
|
|
||||||
<button
|
<button
|
||||||
className={`relative h-14 w-14 shrink-0 overflow-hidden rounded-lg border transition-all ${
|
className={`relative aspect-square w-full overflow-hidden rounded-lg border transition-all ${
|
||||||
selectedCatalogId === toLibraryMaterialRef(item.id)
|
selectedCatalogId === toLibraryMaterialRef(item.id)
|
||||||
? 'border-blue-500 ring-2 ring-blue-500/30'
|
? 'border-blue-500 ring-2 ring-blue-500/30'
|
||||||
: 'border-gray-300 hover:border-gray-400'
|
: 'border-gray-300 hover:border-gray-400'
|
||||||
@@ -200,7 +176,7 @@ export function MaterialPicker({
|
|||||||
))}
|
))}
|
||||||
{selectedCategory === 'other' && onChange ? (
|
{selectedCategory === 'other' && onChange ? (
|
||||||
<button
|
<button
|
||||||
className={`flex h-14 w-14 shrink-0 items-center justify-center rounded-lg border font-medium text-[10px] transition-all ${
|
className={`flex aspect-square w-full items-center justify-center rounded-lg border font-medium text-[10px] transition-all ${
|
||||||
showCustom
|
showCustom
|
||||||
? 'border-blue-500 ring-2 ring-blue-500/30'
|
? 'border-blue-500 ring-2 ring-blue-500/30'
|
||||||
: 'border-gray-300 hover:border-gray-400'
|
: 'border-gray-300 hover:border-gray-400'
|
||||||
@@ -212,7 +188,6 @@ export function MaterialPicker({
|
|||||||
Custom
|
Custom
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -2,9 +2,19 @@
|
|||||||
|
|
||||||
import { ChevronLeft, RotateCcw, X } from 'lucide-react'
|
import { ChevronLeft, RotateCcw, X } from 'lucide-react'
|
||||||
import Image from 'next/image'
|
import Image from 'next/image'
|
||||||
|
import { createContext, useContext } from 'react'
|
||||||
import { useIsMobile } from '../../../hooks/use-mobile'
|
import { useIsMobile } from '../../../hooks/use-mobile'
|
||||||
import { cn } from '../../../lib/utils'
|
import { cn } from '../../../lib/utils'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Host-supplied inspector footer (e.g. community's "Save as preset"). The
|
||||||
|
* `PanelManager` provides it so every panel — including kind-owned
|
||||||
|
* `customPanel`s that render their own `<PanelWrapper>` without threading a
|
||||||
|
* `footer` prop — picks it up without per-kind wiring. An explicit `footer`
|
||||||
|
* prop still wins over the context.
|
||||||
|
*/
|
||||||
|
export const InspectorFooterContext = createContext<React.ReactNode>(null)
|
||||||
|
|
||||||
interface PanelWrapperProps {
|
interface PanelWrapperProps {
|
||||||
title: string
|
title: string
|
||||||
/** Either a URL path (legacy panels pass `/icons/floor.png` etc.,
|
/** Either a URL path (legacy panels pass `/icons/floor.png` etc.,
|
||||||
@@ -34,6 +44,8 @@ export function PanelWrapper({
|
|||||||
width = 320, // default width
|
width = 320, // default width
|
||||||
}: PanelWrapperProps) {
|
}: PanelWrapperProps) {
|
||||||
const isMobile = useIsMobile()
|
const isMobile = useIsMobile()
|
||||||
|
const contextFooter = useContext(InspectorFooterContext)
|
||||||
|
const resolvedFooter = footer ?? contextFooter
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -108,7 +120,9 @@ export function PanelWrapper({
|
|||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="no-scrollbar flex min-h-0 flex-1 flex-col overflow-y-auto">{children}</div>
|
<div className="no-scrollbar flex min-h-0 flex-1 flex-col overflow-y-auto">{children}</div>
|
||||||
|
|
||||||
{footer && <div className="shrink-0 border-border/50 border-t p-3">{footer}</div>}
|
{resolvedFooter && (
|
||||||
|
<div className="shrink-0 border-border/50 border-t p-3">{resolvedFooter}</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import { PanelSection } from '../controls/panel-section'
|
|||||||
import { SegmentedControl } from '../controls/segmented-control'
|
import { SegmentedControl } from '../controls/segmented-control'
|
||||||
import { SliderControl } from '../controls/slider-control'
|
import { SliderControl } from '../controls/slider-control'
|
||||||
import { ToggleControl } from '../controls/toggle-control'
|
import { ToggleControl } from '../controls/toggle-control'
|
||||||
import { PanelWrapper } from './panel-wrapper'
|
import { InspectorFooterContext, PanelWrapper } from './panel-wrapper'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Auto-derived right-panel inspector for any registry-backed node.
|
* Auto-derived right-panel inspector for any registry-backed node.
|
||||||
@@ -87,10 +87,14 @@ export function ParametricInspector({ footer }: { footer?: React.ReactNode } = {
|
|||||||
// panel to cover them.
|
// panel to cover them.
|
||||||
if (parametrics.customPanel) {
|
if (parametrics.customPanel) {
|
||||||
const CustomPanel = resolveCustomPanel(parametrics.customPanel)
|
const CustomPanel = resolveCustomPanel(parametrics.customPanel)
|
||||||
|
// Custom panels render their own `<PanelWrapper>` and don't thread a
|
||||||
|
// `footer` prop, so hand the host footer down via context.
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={null}>
|
<InspectorFooterContext.Provider value={footer}>
|
||||||
<CustomPanel />
|
<Suspense fallback={null}>
|
||||||
</Suspense>
|
<CustomPanel />
|
||||||
|
</Suspense>
|
||||||
|
</InspectorFooterContext.Provider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
import { cn } from './../../../lib/utils'
|
import { cn } from './../../../lib/utils'
|
||||||
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../primitives/tooltip'
|
||||||
|
|
||||||
export type SidebarTab = {
|
export type SidebarTab = {
|
||||||
id: string
|
id: string
|
||||||
@@ -56,34 +57,37 @@ interface IconRailProps {
|
|||||||
/**
|
/**
|
||||||
* Vertical icon rail for the v2 left column. Always visible (even when the
|
* Vertical icon rail for the v2 left column. Always visible (even when the
|
||||||
* panel is collapsed) so the user can reopen the panel by clicking an icon.
|
* panel is collapsed) so the user can reopen the panel by clicking an icon.
|
||||||
* The label renders as a hover tooltip via the native `title`.
|
* The label renders as a hover tooltip on the right.
|
||||||
*/
|
*/
|
||||||
export function IconRail({ tabs, activeTab, collapsed, onIconClick }: IconRailProps) {
|
export function IconRail({ tabs, activeTab, collapsed, onIconClick }: IconRailProps) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full w-12 shrink-0 flex-col items-center gap-1 border-border/50 border-r py-2">
|
<TooltipProvider delayDuration={0} disableHoverableContent>
|
||||||
{tabs.map((tab) => {
|
<div className="flex h-full w-14 shrink-0 flex-col items-center gap-1 border-border/50 border-r py-2">
|
||||||
// While expanded, the active tab is filled. While collapsed, nothing
|
{tabs.map((tab) => {
|
||||||
// is "open", so the active tab reads as a muted highlight instead.
|
// Only show the active highlight while the panel is open. When
|
||||||
const isActive = activeTab === tab.id
|
// collapsed nothing is "open", so every icon reads as unselected.
|
||||||
return (
|
const showActive = activeTab === tab.id && !collapsed
|
||||||
<button
|
return (
|
||||||
className={cn(
|
<Tooltip key={tab.id}>
|
||||||
'flex h-9 w-9 items-center justify-center rounded-lg transition-colors',
|
<TooltipTrigger asChild>
|
||||||
isActive && !collapsed
|
<button
|
||||||
? 'bg-accent text-foreground'
|
className={cn(
|
||||||
: isActive
|
'group flex h-11 w-11 items-center justify-center rounded-xl transition-all duration-200 [&_img]:transition-[opacity,filter] [&_img]:duration-200',
|
||||||
? 'text-foreground'
|
showActive
|
||||||
: 'text-muted-foreground hover:bg-accent/50 hover:text-foreground',
|
? 'bg-accent text-foreground shadow-sm [&_img]:opacity-100 [&_img]:grayscale-0'
|
||||||
)}
|
: 'text-muted-foreground hover:bg-accent/50 hover:text-foreground [&_img]:opacity-60 [&_img]:grayscale hover:[&_img]:opacity-100 hover:[&_img]:grayscale-0',
|
||||||
key={tab.id}
|
)}
|
||||||
onClick={() => onIconClick(tab.id)}
|
onClick={() => onIconClick(tab.id)}
|
||||||
title={tab.label}
|
type="button"
|
||||||
type="button"
|
>
|
||||||
>
|
{tab.icon ?? tab.label.charAt(0)}
|
||||||
{tab.icon ?? tab.label.charAt(0)}
|
</button>
|
||||||
</button>
|
</TooltipTrigger>
|
||||||
)
|
<TooltipContent side="right">{tab.label}</TooltipContent>
|
||||||
})}
|
</Tooltip>
|
||||||
</div>
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</TooltipProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,62 +0,0 @@
|
|||||||
import { type AnyNodeId, useScene } from '@pascal-app/core'
|
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
|
||||||
import { useMemo } from 'react'
|
|
||||||
import { useShallow } from 'zustand/react/shallow'
|
|
||||||
import useEditor, { type StructureTool } from '../store/use-editor'
|
|
||||||
|
|
||||||
export function useContextualTools() {
|
|
||||||
const selection = useViewer((s) => s.selection)
|
|
||||||
// Only resubscribe when the *types* of selected nodes change, not when any
|
|
||||||
// node in the scene mutates.
|
|
||||||
const selectedTypes = useScene(
|
|
||||||
useShallow((s) =>
|
|
||||||
selection.selectedIds.map((id) => s.nodes[id as AnyNodeId]?.type).filter(Boolean),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
const structureLayer = useEditor((s) => s.structureLayer)
|
|
||||||
|
|
||||||
return useMemo(() => {
|
|
||||||
// If we are in the zones layer, only zone tool is relevant
|
|
||||||
if (structureLayer === 'zones') {
|
|
||||||
return ['zone'] as StructureTool[]
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default tools when nothing is selected
|
|
||||||
const defaultTools: StructureTool[] = [
|
|
||||||
'wall',
|
|
||||||
'fence',
|
|
||||||
'slab',
|
|
||||||
'ceiling',
|
|
||||||
'roof',
|
|
||||||
'elevator',
|
|
||||||
'door',
|
|
||||||
'window',
|
|
||||||
]
|
|
||||||
|
|
||||||
if (selectedTypes.length === 0) {
|
|
||||||
return defaultTools
|
|
||||||
}
|
|
||||||
|
|
||||||
// If a wall is selected, prioritize wall-hosted elements
|
|
||||||
if (selectedTypes.includes('wall')) {
|
|
||||||
return ['window', 'door', 'wall', 'fence'] as StructureTool[]
|
|
||||||
}
|
|
||||||
|
|
||||||
// If a slab is selected, prioritize slab editing
|
|
||||||
if (selectedTypes.includes('slab')) {
|
|
||||||
return ['slab', 'wall'] as StructureTool[]
|
|
||||||
}
|
|
||||||
|
|
||||||
// If a ceiling is selected, prioritize ceiling editing
|
|
||||||
if (selectedTypes.includes('ceiling')) {
|
|
||||||
return ['ceiling'] as StructureTool[]
|
|
||||||
}
|
|
||||||
|
|
||||||
// If a roof is selected, prioritize roof editing
|
|
||||||
if (selectedTypes.includes('roof')) {
|
|
||||||
return ['roof'] as StructureTool[]
|
|
||||||
}
|
|
||||||
|
|
||||||
return defaultTools
|
|
||||||
}, [selectedTypes, structureLayer])
|
|
||||||
}
|
|
||||||
@@ -116,6 +116,7 @@ export {
|
|||||||
} from './components/ui/action-menu/view-toggles'
|
} from './components/ui/action-menu/view-toggles'
|
||||||
export { useCommandPalette } from './components/ui/command-palette'
|
export { useCommandPalette } from './components/ui/command-palette'
|
||||||
export { ActionButton, ActionGroup } from './components/ui/controls/action-button'
|
export { ActionButton, ActionGroup } from './components/ui/controls/action-button'
|
||||||
|
export { MaterialPaintPanel } from './components/ui/controls/material-paint-panel'
|
||||||
export { MaterialPicker } from './components/ui/controls/material-picker'
|
export { MaterialPicker } from './components/ui/controls/material-picker'
|
||||||
export { MetricControl } from './components/ui/controls/metric-control'
|
export { MetricControl } from './components/ui/controls/metric-control'
|
||||||
export { PanelSection } from './components/ui/controls/panel-section'
|
export { PanelSection } from './components/ui/controls/panel-section'
|
||||||
|
|||||||
@@ -1,11 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import {
|
import { type CeilingNode, resolveLevelId, useLiveNodeOverrides, useScene } from '@pascal-app/core'
|
||||||
type CeilingNode,
|
|
||||||
resolveLevelId,
|
|
||||||
useLiveNodeOverrides,
|
|
||||||
useScene,
|
|
||||||
} from '@pascal-app/core'
|
|
||||||
import { PolygonEditor } from '@pascal-app/editor'
|
import { PolygonEditor } from '@pascal-app/editor'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { useCallback, useEffect } from 'react'
|
import { useCallback, useEffect } from 'react'
|
||||||
|
|||||||
@@ -1,11 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import {
|
import { type CeilingNode, resolveLevelId, useLiveNodeOverrides, useScene } from '@pascal-app/core'
|
||||||
type CeilingNode,
|
|
||||||
resolveLevelId,
|
|
||||||
useLiveNodeOverrides,
|
|
||||||
useScene,
|
|
||||||
} from '@pascal-app/core'
|
|
||||||
import { PolygonEditor } from '@pascal-app/editor'
|
import { PolygonEditor } from '@pascal-app/editor'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { useCallback, useEffect } from 'react'
|
import { useCallback, useEffect } from 'react'
|
||||||
|
|||||||
@@ -154,20 +154,14 @@ function columnBraceHandle(axis: 'x' | 'z'): HandleDescriptor<ColumnNodeType> {
|
|||||||
axis,
|
axis,
|
||||||
anchor: 'center',
|
anchor: 'center',
|
||||||
min: MIN_BRACE_DIMENSION,
|
min: MIN_BRACE_DIMENSION,
|
||||||
currentValue: (n) =>
|
currentValue: (n) => (axis === 'x' ? (n.braceWidth ?? n.width) : (n.braceDepth ?? n.depth)),
|
||||||
axis === 'x' ? (n.braceWidth ?? n.width) : (n.braceDepth ?? n.depth),
|
apply: (_n, newValue) => (axis === 'x' ? { braceWidth: newValue } : { braceDepth: newValue }),
|
||||||
apply: (_n, newValue) =>
|
|
||||||
axis === 'x' ? { braceWidth: newValue } : { braceDepth: newValue },
|
|
||||||
placement: {
|
placement: {
|
||||||
position: (n) => {
|
position: (n) => {
|
||||||
// Position outside any splay so the arrow clears the legs.
|
// Position outside any splay so the arrow clears the legs.
|
||||||
const half =
|
const half =
|
||||||
axis === 'x'
|
axis === 'x'
|
||||||
? Math.max(
|
? Math.max(n.braceBottomSpread ?? 0, n.braceTopSpread ?? 0, n.braceWidth ?? n.width) / 2
|
||||||
n.braceBottomSpread ?? 0,
|
|
||||||
n.braceTopSpread ?? 0,
|
|
||||||
n.braceWidth ?? n.width,
|
|
||||||
) / 2
|
|
||||||
: (n.braceDepth ?? n.depth) / 2
|
: (n.braceDepth ?? n.depth) / 2
|
||||||
return axis === 'x'
|
return axis === 'x'
|
||||||
? [half + BRACE_HANDLE_OFFSET, n.height / 2, 0]
|
? [half + BRACE_HANDLE_OFFSET, n.height / 2, 0]
|
||||||
@@ -205,12 +199,7 @@ function columnFootprintHalf(n: ColumnNodeType): { halfX: number; halfZ: number
|
|||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
halfX:
|
halfX:
|
||||||
Math.max(
|
Math.max(n.width, n.braceWidth ?? 0, n.braceBottomSpread ?? 0, n.braceTopSpread ?? 0) / 2,
|
||||||
n.width,
|
|
||||||
n.braceWidth ?? 0,
|
|
||||||
n.braceBottomSpread ?? 0,
|
|
||||||
n.braceTopSpread ?? 0,
|
|
||||||
) / 2,
|
|
||||||
halfZ: Math.max(n.depth, n.braceDepth ?? 0) / 2,
|
halfZ: Math.max(n.depth, n.braceDepth ?? 0) / 2,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,8 +53,7 @@ export const columnResizeAffordance: FloorplanAffordance<ColumnNode> = {
|
|||||||
const initialRadius = node.radius
|
const initialRadius = node.radius
|
||||||
const initialBraceWidth = node.braceWidth ?? node.width
|
const initialBraceWidth = node.braceWidth ?? node.width
|
||||||
const initialBraceDepth = node.braceDepth ?? node.depth
|
const initialBraceDepth = node.braceDepth ?? node.depth
|
||||||
const initialBraceBottomSpread =
|
const initialBraceBottomSpread = node.braceBottomSpread ?? Math.max(node.width * 3, 1.2)
|
||||||
node.braceBottomSpread ?? Math.max(node.width * 3, 1.2)
|
|
||||||
const initialBraceTopSpread = node.braceTopSpread ?? 0.12
|
const initialBraceTopSpread = node.braceTopSpread ?? 0.12
|
||||||
|
|
||||||
let lastPatch: Partial<ColumnNode> = {}
|
let lastPatch: Partial<ColumnNode> = {}
|
||||||
@@ -110,10 +109,7 @@ export const columnResizeAffordance: FloorplanAffordance<ColumnNode> = {
|
|||||||
return
|
return
|
||||||
case 'brace-top-spread':
|
case 'brace-top-spread':
|
||||||
commitPatch({
|
commitPatch({
|
||||||
braceTopSpread: Math.max(
|
braceTopSpread: Math.max(MIN_BRACE_TOP_SPREAD, initialBraceTopSpread + 2 * projDelta),
|
||||||
MIN_BRACE_TOP_SPREAD,
|
|
||||||
initialBraceTopSpread + 2 * projDelta,
|
|
||||||
),
|
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -102,8 +102,7 @@ export function buildColumnFloorplan(
|
|||||||
// outward tip in plan coords. Captured at emit-time so the
|
// outward tip in plan coords. Captured at emit-time so the
|
||||||
// affordance doesn't need to recompute `column.rotation` (and
|
// affordance doesn't need to recompute `column.rotation` (and
|
||||||
// a mid-drag rotation can't drift the projection basis).
|
// a mid-drag rotation can't drift the projection basis).
|
||||||
const outwardLocal: [number, number] =
|
const outwardLocal: [number, number] = localDirection === 'x' ? [1, 0] : [0, 1]
|
||||||
localDirection === 'x' ? [1, 0] : [0, 1]
|
|
||||||
const [planAxisX, planAxisY] = rotatePlanVector(outwardLocal[0], outwardLocal[1], rot)
|
const [planAxisX, planAxisY] = rotatePlanVector(outwardLocal[0], outwardLocal[1], rot)
|
||||||
children.push({
|
children.push({
|
||||||
kind: 'move-arrow',
|
kind: 'move-arrow',
|
||||||
|
|||||||
@@ -355,9 +355,7 @@ export default function ColumnPanel() {
|
|||||||
// so the preset's braceWidth / braceDepth win over
|
// so the preset's braceWidth / braceDepth win over
|
||||||
// the carried-from-previous-style values.
|
// the carried-from-previous-style values.
|
||||||
const stylePreset =
|
const stylePreset =
|
||||||
option.value === 'vertical'
|
option.value === 'vertical' ? {} : SUPPORT_STYLE_DEFAULTS[option.value]
|
||||||
? {}
|
|
||||||
: SUPPORT_STYLE_DEFAULTS[option.value]
|
|
||||||
handleUpdate({
|
handleUpdate({
|
||||||
supportStyle: option.value,
|
supportStyle: option.value,
|
||||||
...(option.value !== 'vertical'
|
...(option.value !== 'vertical'
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
type ElevatorNode as ElevatorNodeType,
|
|
||||||
ElevatorNode as ElevatorNodeSchema,
|
ElevatorNode as ElevatorNodeSchema,
|
||||||
|
type ElevatorNode as ElevatorNodeType,
|
||||||
getElevatorCabDepth,
|
getElevatorCabDepth,
|
||||||
getElevatorCabWidth,
|
getElevatorCabWidth,
|
||||||
getElevatorShaftDepth,
|
getElevatorShaftDepth,
|
||||||
@@ -10,11 +10,8 @@ import {
|
|||||||
type NodeDefinition,
|
type NodeDefinition,
|
||||||
resolveElevatorLevels,
|
resolveElevatorLevels,
|
||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
import {
|
|
||||||
elevatorResizeAffordance,
|
|
||||||
elevatorRotateAffordance,
|
|
||||||
} from './floorplan-affordances'
|
|
||||||
import { buildElevatorFloorplan } from './floorplan'
|
import { buildElevatorFloorplan } from './floorplan'
|
||||||
|
import { elevatorResizeAffordance, elevatorRotateAffordance } from './floorplan-affordances'
|
||||||
import { elevatorParametrics } from './parametrics'
|
import { elevatorParametrics } from './parametrics'
|
||||||
import { ElevatorNode } from './schema'
|
import { ElevatorNode } from './schema'
|
||||||
|
|
||||||
|
|||||||
@@ -326,7 +326,12 @@ export function buildElevatorFloorplan(
|
|||||||
{ local: [outerHalfW + sideArrowOffset, 0], localAngle: 0, axis: 'x', side: 1 },
|
{ local: [outerHalfW + sideArrowOffset, 0], localAngle: 0, axis: 'x', side: 1 },
|
||||||
{ local: [-(outerHalfW + sideArrowOffset), 0], localAngle: Math.PI, axis: 'x', side: -1 },
|
{ local: [-(outerHalfW + sideArrowOffset), 0], localAngle: Math.PI, axis: 'x', side: -1 },
|
||||||
{ local: [0, outerHalfD + sideArrowOffset], localAngle: Math.PI / 2, axis: 'z', side: 1 },
|
{ local: [0, outerHalfD + sideArrowOffset], localAngle: Math.PI / 2, axis: 'z', side: 1 },
|
||||||
{ local: [0, -(outerHalfD + sideArrowOffset)], localAngle: -Math.PI / 2, axis: 'z', side: -1 },
|
{
|
||||||
|
local: [0, -(outerHalfD + sideArrowOffset)],
|
||||||
|
localAngle: -Math.PI / 2,
|
||||||
|
axis: 'z',
|
||||||
|
side: -1,
|
||||||
|
},
|
||||||
]
|
]
|
||||||
for (const side of sides) {
|
for (const side of sides) {
|
||||||
const [ox, oz] = rotate(side.local[0], side.local[1])
|
const [ox, oz] = rotate(side.local[0], side.local[1])
|
||||||
|
|||||||
@@ -3,15 +3,15 @@ import {
|
|||||||
getPitchFromActiveRoofHeight,
|
getPitchFromActiveRoofHeight,
|
||||||
type HandleDescriptor,
|
type HandleDescriptor,
|
||||||
type NodeDefinition,
|
type NodeDefinition,
|
||||||
type RoofSegmentNode as RoofSegmentNodeType,
|
|
||||||
RoofSegmentNode as RoofSegmentNodeSchema,
|
RoofSegmentNode as RoofSegmentNodeSchema,
|
||||||
|
type RoofSegmentNode as RoofSegmentNodeType,
|
||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
|
import { buildRoofSegmentFloorplan } from './floorplan'
|
||||||
import {
|
import {
|
||||||
roofSegmentMoveTarget,
|
roofSegmentMoveTarget,
|
||||||
roofSegmentResizeAffordance,
|
roofSegmentResizeAffordance,
|
||||||
roofSegmentRotateAffordance,
|
roofSegmentRotateAffordance,
|
||||||
} from './floorplan-affordances'
|
} from './floorplan-affordances'
|
||||||
import { buildRoofSegmentFloorplan } from './floorplan'
|
|
||||||
import { roofSegmentParametrics } from './parametrics'
|
import { roofSegmentParametrics } from './parametrics'
|
||||||
import { RoofSegmentNode } from './schema'
|
import { RoofSegmentNode } from './schema'
|
||||||
|
|
||||||
|
|||||||
@@ -154,11 +154,7 @@ export const roofSegmentMoveTarget: FloorplanMoveTarget<RoofSegmentNode> = ({ no
|
|||||||
// is `[cosRoof, sinRoof; -sinRoof, cosRoof]`. Used to project world cursor
|
// is `[cosRoof, sinRoof; -sinRoof, cosRoof]`. Used to project world cursor
|
||||||
// back into roof-local coords.
|
// back into roof-local coords.
|
||||||
void roofRot
|
void roofRot
|
||||||
let lastLocal: [number, number, number] = [
|
let lastLocal: [number, number, number] = [node.position[0], node.position[1], node.position[2]]
|
||||||
node.position[0],
|
|
||||||
node.position[1],
|
|
||||||
node.position[2],
|
|
||||||
]
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
affectedIds: [segmentId],
|
affectedIds: [segmentId],
|
||||||
|
|||||||
@@ -1,10 +1,6 @@
|
|||||||
import type {
|
import type { HandleDescriptor, NodeDefinition, ShelfNode as ShelfNodeType } from '@pascal-app/core'
|
||||||
HandleDescriptor,
|
|
||||||
NodeDefinition,
|
|
||||||
ShelfNode as ShelfNodeType,
|
|
||||||
} from '@pascal-app/core'
|
|
||||||
import { shelfResizeAffordance, shelfRotateAffordance } from './floorplan-affordances'
|
|
||||||
import { buildShelfFloorplan } from './floorplan'
|
import { buildShelfFloorplan } from './floorplan'
|
||||||
|
import { shelfResizeAffordance, shelfRotateAffordance } from './floorplan-affordances'
|
||||||
import { shelfFloorplanMoveTarget } from './floorplan-move'
|
import { shelfFloorplanMoveTarget } from './floorplan-move'
|
||||||
import { buildShelfGeometry, shelfRowSurfaceYs } from './geometry'
|
import { buildShelfGeometry, shelfRowSurfaceYs } from './geometry'
|
||||||
import { shelfParametrics } from './parametrics'
|
import { shelfParametrics } from './parametrics'
|
||||||
|
|||||||
@@ -24,10 +24,7 @@ const ROTATE_ARROW_CORNER_OFFSET = 0.22
|
|||||||
* corner. Body move continues to flow through `shelfFloorplanMoveTarget`
|
* corner. Body move continues to flow through `shelfFloorplanMoveTarget`
|
||||||
* (engaged from the action-menu Move button, not from these arrows).
|
* (engaged from the action-menu Move button, not from these arrows).
|
||||||
*/
|
*/
|
||||||
export function buildShelfFloorplan(
|
export function buildShelfFloorplan(node: ShelfNode, ctx?: GeometryContext): FloorplanGeometry {
|
||||||
node: ShelfNode,
|
|
||||||
ctx?: GeometryContext,
|
|
||||||
): FloorplanGeometry {
|
|
||||||
const [px, , pz] = node.position
|
const [px, , pz] = node.position
|
||||||
const ry = node.rotation[1] ?? 0
|
const ry = node.rotation[1] ?? 0
|
||||||
// Floor-plan plots at `-ry` so SVG's CW-with-y-down `rotate` direction
|
// Floor-plan plots at `-ry` so SVG's CW-with-y-down `rotate` direction
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import {
|
import {
|
||||||
type HandleDescriptor,
|
type HandleDescriptor,
|
||||||
type NodeDefinition,
|
type NodeDefinition,
|
||||||
type StairNode as StairNodeType,
|
|
||||||
StairNode as StairNodeSchema,
|
StairNode as StairNodeSchema,
|
||||||
|
type StairNode as StairNodeType,
|
||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
|
|
||||||
const MIN_CURVED_RISE = 0.3
|
const MIN_CURVED_RISE = 0.3
|
||||||
@@ -223,11 +223,7 @@ function stairRotateGizmoPosition(n: StairNodeType): [number, number, number] {
|
|||||||
}
|
}
|
||||||
const width = Math.max(n.width ?? 1, MIN_CURVED_WIDTH)
|
const width = Math.max(n.width ?? 1, MIN_CURVED_WIDTH)
|
||||||
const yMid = Math.max(n.totalRise ?? 2.5, 0.1) / 2
|
const yMid = Math.max(n.totalRise ?? 2.5, 0.1) / 2
|
||||||
return [
|
return [width / 2 + STAIR_ROTATE_CORNER_OFFSET, yMid, -STAIR_ROTATE_CORNER_OFFSET]
|
||||||
width / 2 + STAIR_ROTATE_CORNER_OFFSET,
|
|
||||||
yMid,
|
|
||||||
-STAIR_ROTATE_CORNER_OFFSET,
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function stairRotateHandle(): HandleDescriptor<StairNodeType> {
|
function stairRotateHandle(): HandleDescriptor<StairNodeType> {
|
||||||
@@ -288,6 +284,8 @@ function stairHandles(node: StairNodeType): HandleDescriptor<StairNodeType>[] {
|
|||||||
handles.push(stairRotateHandle())
|
handles.push(stairRotateHandle())
|
||||||
return handles
|
return handles
|
||||||
}
|
}
|
||||||
|
|
||||||
|
import { buildStairFloorplan } from './floorplan'
|
||||||
import {
|
import {
|
||||||
curvedStairInnerRadiusAffordance,
|
curvedStairInnerRadiusAffordance,
|
||||||
curvedStairSweepAffordance,
|
curvedStairSweepAffordance,
|
||||||
@@ -296,7 +294,6 @@ import {
|
|||||||
segmentWidthAffordance,
|
segmentWidthAffordance,
|
||||||
stairRotateAffordance,
|
stairRotateAffordance,
|
||||||
} from './floorplan-affordances'
|
} from './floorplan-affordances'
|
||||||
import { buildStairFloorplan } from './floorplan'
|
|
||||||
import { stairFloorplanMoveTarget } from './floorplan-move'
|
import { stairFloorplanMoveTarget } from './floorplan-move'
|
||||||
import { stairParametrics } from './parametrics'
|
import { stairParametrics } from './parametrics'
|
||||||
import { StairNode } from './schema'
|
import { StairNode } from './schema'
|
||||||
|
|||||||
@@ -146,10 +146,7 @@ export const curvedStairWidthAffordance: FloorplanAffordance<StairNode> = {
|
|||||||
affectedIds: [stairId],
|
affectedIds: [stairId],
|
||||||
apply({ planPoint }) {
|
apply({ planPoint }) {
|
||||||
const currentRadial = (planPoint[0] - cx) * radialX + (planPoint[1] - cz) * radialZ
|
const currentRadial = (planPoint[0] - cx) * radialX + (planPoint[1] - cz) * radialZ
|
||||||
const newWidth = Math.max(
|
const newWidth = Math.max(MIN_CURVED_WIDTH, initialWidth + (currentRadial - initialRadial))
|
||||||
MIN_CURVED_WIDTH,
|
|
||||||
initialWidth + (currentRadial - initialRadial),
|
|
||||||
)
|
|
||||||
lastWidth = newWidth
|
lastWidth = newWidth
|
||||||
useScene.getState().updateNode(stairId, { width: newWidth })
|
useScene.getState().updateNode(stairId, { width: newWidth })
|
||||||
},
|
},
|
||||||
@@ -172,7 +169,9 @@ export const curvedStairInnerRadiusAffordance: FloorplanAffordance<StairNode> =
|
|||||||
start({ node, initialPlanPoint }) {
|
start({ node, initialPlanPoint }) {
|
||||||
const stairId = node.id as AnyNodeId
|
const stairId = node.id as AnyNodeId
|
||||||
const isSpiral = node.stairType === 'spiral'
|
const isSpiral = node.stairType === 'spiral'
|
||||||
const minInnerRadius = isSpiral ? MIN_CURVED_INNER_RADIUS_SPIRAL : MIN_CURVED_INNER_RADIUS_CURVED
|
const minInnerRadius = isSpiral
|
||||||
|
? MIN_CURVED_INNER_RADIUS_SPIRAL
|
||||||
|
: MIN_CURVED_INNER_RADIUS_CURVED
|
||||||
const initialInnerRadius = Math.max(minInnerRadius, node.innerRadius ?? 0.9)
|
const initialInnerRadius = Math.max(minInnerRadius, node.innerRadius ?? 0.9)
|
||||||
const initialWidth = Math.max(node.width ?? 1, MIN_CURVED_WIDTH)
|
const initialWidth = Math.max(node.width ?? 1, MIN_CURVED_WIDTH)
|
||||||
const initialOuterRadius = initialInnerRadius + initialWidth
|
const initialOuterRadius = initialInnerRadius + initialWidth
|
||||||
@@ -303,9 +302,7 @@ export const curvedStairSweepAffordance: FloorplanAffordance<StairNode> = {
|
|||||||
return true
|
return true
|
||||||
},
|
},
|
||||||
commit() {
|
commit() {
|
||||||
useScene
|
useScene.getState().updateNode(stairId, { sweepAngle: lastSweep, rotation: lastRotation })
|
||||||
.getState()
|
|
||||||
.updateNode(stairId, { sweepAngle: lastSweep, rotation: lastRotation })
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import type {
|
|||||||
// `definition.ts` so the 2D handle visually lines up with where the 3D
|
// `definition.ts` so the 2D handle visually lines up with where the 3D
|
||||||
// curved-arrow gizmo would sit at the matching world point.
|
// curved-arrow gizmo would sit at the matching world point.
|
||||||
const STAIR_ROTATE_PLAN_OFFSET = 0.4
|
const STAIR_ROTATE_PLAN_OFFSET = 0.4
|
||||||
|
|
||||||
import {
|
import {
|
||||||
buildFloorplanStairEntry,
|
buildFloorplanStairEntry,
|
||||||
buildSvgAnnularSectorPath,
|
buildSvgAnnularSectorPath,
|
||||||
@@ -134,14 +135,8 @@ export function buildStairFloorplan(
|
|||||||
// Segment-local +X (width axis) and +Z (run axis) in plan coords,
|
// Segment-local +X (width axis) and +Z (run axis) in plan coords,
|
||||||
// captured here so the affordance handler can project pointer
|
// captured here so the affordance handler can project pointer
|
||||||
// deltas without re-walking the stair chain.
|
// deltas without re-walking the stair chain.
|
||||||
const axisX: readonly [number, number] = [
|
const axisX: readonly [number, number] = [(c1.x - c0.x) / width, (c1.y - c0.y) / width]
|
||||||
(c1.x - c0.x) / width,
|
const axisZ: readonly [number, number] = [(c3.x - c0.x) / length, (c3.y - c0.y) / length]
|
||||||
(c1.y - c0.y) / width,
|
|
||||||
]
|
|
||||||
const axisZ: readonly [number, number] = [
|
|
||||||
(c3.x - c0.x) / length,
|
|
||||||
(c3.y - c0.y) / length,
|
|
||||||
]
|
|
||||||
const rightMid: [number, number] = [(c1.x + c2.x) / 2, (c1.y + c2.y) / 2]
|
const rightMid: [number, number] = [(c1.x + c2.x) / 2, (c1.y + c2.y) / 2]
|
||||||
const leftMid: [number, number] = [(c0.x + c3.x) / 2, (c0.y + c3.y) / 2]
|
const leftMid: [number, number] = [(c0.x + c3.x) / 2, (c0.y + c3.y) / 2]
|
||||||
const frontEdgeMid: [number, number] = [(c2.x + c3.x) / 2, (c2.y + c3.y) / 2]
|
const frontEdgeMid: [number, number] = [(c2.x + c3.x) / 2, (c2.y + c3.y) / 2]
|
||||||
@@ -277,13 +272,7 @@ export function buildStairFloorplan(
|
|||||||
// steps past `dashedFromIndex` are dashed.
|
// steps past `dashedFromIndex` are dashed.
|
||||||
const isEmphasised = stairType === 'spiral' ? isLast : isFirst || isLast
|
const isEmphasised = stairType === 'spiral' ? isLast : isFirst || isLast
|
||||||
const stepWidth =
|
const stepWidth =
|
||||||
stairType === 'spiral'
|
stairType === 'spiral' ? (isEmphasised ? 1.8 : 1.15) : isEmphasised ? 1.5 : 1.1
|
||||||
? isEmphasised
|
|
||||||
? 1.8
|
|
||||||
: 1.15
|
|
||||||
: isEmphasised
|
|
||||||
? 1.5
|
|
||||||
: 1.1
|
|
||||||
children.push({
|
children.push({
|
||||||
kind: 'line',
|
kind: 'line',
|
||||||
x1: inner.x,
|
x1: inner.x,
|
||||||
@@ -455,10 +444,7 @@ export function buildStairFloorplan(
|
|||||||
localZ = -STAIR_ROTATE_PLAN_OFFSET
|
localZ = -STAIR_ROTATE_PLAN_OFFSET
|
||||||
} else {
|
} else {
|
||||||
const isSpiral = stairType === 'spiral'
|
const isSpiral = stairType === 'spiral'
|
||||||
const innerR = Math.max(
|
const innerR = Math.max(isSpiral ? 0.05 : 0.2, stair.innerRadius ?? (isSpiral ? 0.2 : 0.9))
|
||||||
isSpiral ? 0.05 : 0.2,
|
|
||||||
stair.innerRadius ?? (isSpiral ? 0.2 : 0.9),
|
|
||||||
)
|
|
||||||
const outerR = innerR + (stair.width ?? 1)
|
const outerR = innerR + (stair.width ?? 1)
|
||||||
const sweep = stair.sweepAngle ?? (isSpiral ? Math.PI * 2 : Math.PI / 2)
|
const sweep = stair.sweepAngle ?? (isSpiral ? Math.PI * 2 : Math.PI / 2)
|
||||||
const radius = outerR + STAIR_ROTATE_PLAN_OFFSET
|
const radius = outerR + STAIR_ROTATE_PLAN_OFFSET
|
||||||
|
|||||||
@@ -651,10 +651,7 @@ function SpiralStepSupportMesh({
|
|||||||
const sizeX = Math.max(0.04, innerRadius - spiralColumnRadius + 0.04)
|
const sizeX = Math.max(0.04, innerRadius - spiralColumnRadius + 0.04)
|
||||||
const sizeY = Math.max(thickness * 0.55, 0.025)
|
const sizeY = Math.max(thickness * 0.55, 0.025)
|
||||||
const sizeZ = Math.max(0.04, Math.min(0.12, sizeY * 1.5))
|
const sizeZ = Math.max(0.04, Math.min(0.12, sizeY * 1.5))
|
||||||
const geometry = useMemo(
|
const geometry = useMemo(() => new THREE.BoxGeometry(sizeX, sizeY, sizeZ), [sizeX, sizeY, sizeZ])
|
||||||
() => new THREE.BoxGeometry(sizeX, sizeY, sizeZ),
|
|
||||||
[sizeX, sizeY, sizeZ],
|
|
||||||
)
|
|
||||||
useEffect(
|
useEffect(
|
||||||
() => () => {
|
() => () => {
|
||||||
geometry.dispose()
|
geometry.dispose()
|
||||||
|
|||||||
@@ -187,13 +187,15 @@ export const wallFloorplanMoveTarget: FloorplanMoveTarget<WallNode> = ({ node })
|
|||||||
// until the user commits. Batched into a single zustand
|
// until the user commits. Batched into a single zustand
|
||||||
// notification — otherwise each per-wall `.set` would re-render
|
// notification — otherwise each per-wall `.set` would re-render
|
||||||
// every override subscriber once per linked wall per tick.
|
// every override subscriber once per linked wall per tick.
|
||||||
useLiveNodeOverrides.getState().setMany([
|
useLiveNodeOverrides
|
||||||
[wallId, { start: nextStart, end: nextEnd }],
|
.getState()
|
||||||
...linkedUpdates.map(
|
.setMany([
|
||||||
(upd) =>
|
[wallId, { start: nextStart, end: nextEnd }],
|
||||||
[upd.id, { start: upd.start, end: upd.end }] as [string, Record<string, unknown>],
|
...linkedUpdates.map(
|
||||||
),
|
(upd) =>
|
||||||
])
|
[upd.id, { start: upd.start, end: upd.end }] as [string, Record<string, unknown>],
|
||||||
|
),
|
||||||
|
])
|
||||||
|
|
||||||
// Surface bridge-wall previews so the floor-plan SVG layer can
|
// Surface bridge-wall previews so the floor-plan SVG layer can
|
||||||
// render dashed outlines of what `commit()` will insert. Mirrors
|
// render dashed outlines of what `commit()` will insert. Mirrors
|
||||||
|
|||||||
@@ -460,9 +460,7 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
|
|||||||
if (axis) {
|
if (axis) {
|
||||||
const originalProj = originalCenter[0] * axis[0] + originalCenter[1] * axis[1]
|
const originalProj = originalCenter[0] * axis[0] + originalCenter[1] * axis[1]
|
||||||
const rawProj = originalProj + rawDeltaX * axis[0] + rawDeltaZ * axis[1]
|
const rawProj = originalProj + rawDeltaX * axis[0] + rawDeltaZ * axis[1]
|
||||||
const snappedProj = shiftPressedRef.current
|
const snappedProj = shiftPressedRef.current ? rawProj : snapScalarToGrid(rawProj, snapStep)
|
||||||
? rawProj
|
|
||||||
: snapScalarToGrid(rawProj, snapStep)
|
|
||||||
const perpDelta = snappedProj - originalProj
|
const perpDelta = snappedProj - originalProj
|
||||||
deltaX = axis[0] * perpDelta
|
deltaX = axis[0] * perpDelta
|
||||||
deltaZ = axis[1] * perpDelta
|
deltaZ = axis[1] * perpDelta
|
||||||
|
|||||||
@@ -62,10 +62,7 @@ export const ParametricNodeRenderer = ({ node }: { node: AnyNode }) => {
|
|||||||
// of snapping only on commit. Per-node subscription so unrelated
|
// of snapping only on commit. Per-node subscription so unrelated
|
||||||
// override writes don't re-render the whole tree.
|
// override writes don't re-render the whole tree.
|
||||||
const liveOverride = useLiveNodeOverrides((s) => s.overrides.get(node.id))
|
const liveOverride = useLiveNodeOverrides((s) => s.overrides.get(node.id))
|
||||||
const overrideRotation = liveOverride?.rotation as
|
const overrideRotation = liveOverride?.rotation as [number, number, number] | number | undefined
|
||||||
| [number, number, number]
|
|
||||||
| number
|
|
||||||
| undefined
|
|
||||||
const overridePosition = liveOverride?.position as [number, number, number] | undefined
|
const overridePosition = liveOverride?.position as [number, number, number] | undefined
|
||||||
|
|
||||||
useRegistry(node.id, node.type, ref)
|
useRegistry(node.id, node.type, ref)
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ import {
|
|||||||
DEFAULT_WALL_HEIGHT,
|
DEFAULT_WALL_HEIGHT,
|
||||||
type DoorNode,
|
type DoorNode,
|
||||||
getAdjacentWallIds,
|
getAdjacentWallIds,
|
||||||
|
getEffectiveNode,
|
||||||
getWallCurveFrameAt,
|
getWallCurveFrameAt,
|
||||||
getWallMiterBoundaryPoints,
|
getWallMiterBoundaryPoints,
|
||||||
getEffectiveNode,
|
|
||||||
getWallPlanFootprint,
|
getWallPlanFootprint,
|
||||||
getWallSurfacePolygon,
|
getWallSurfacePolygon,
|
||||||
getWallThickness,
|
getWallThickness,
|
||||||
|
|||||||
Reference in New Issue
Block a user