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'
|
||||
export {
|
||||
default as useLiveNodeOverrides,
|
||||
type LiveNodeOverrides,
|
||||
getEffectiveNode,
|
||||
type LiveNodeOverrides,
|
||||
} from './store/use-live-node-overrides'
|
||||
export { default as useLiveTransforms, type LiveTransform } from './store/use-live-transforms'
|
||||
export { clearSceneHistory, default as useScene } from './store/use-scene'
|
||||
|
||||
@@ -189,11 +189,7 @@ export type EndpointMoveHandle<N> = {
|
||||
endpoint: 'start' | 'end'
|
||||
placement: HandlePlacement<N>
|
||||
/** Called with the world-space hit on the ground plane. */
|
||||
apply: (
|
||||
node: N,
|
||||
worldPoint: readonly [number, number, number],
|
||||
sceneApi: SceneApi,
|
||||
) => Partial<N>
|
||||
apply: (node: N, worldPoint: readonly [number, number, number], sceneApi: SceneApi) => Partial<N>
|
||||
portal?: HandlePortal
|
||||
}
|
||||
|
||||
|
||||
@@ -100,9 +100,7 @@ export function FloorplanRegistryActionMenu() {
|
||||
return
|
||||
}
|
||||
|
||||
const el = sceneEl.querySelector(
|
||||
`[data-node-id="${selectedId}"]`,
|
||||
) as SVGGElement | null
|
||||
const el = sceneEl.querySelector(`[data-node-id="${selectedId}"]`) as SVGGElement | null
|
||||
if (el) {
|
||||
const rect = el.getBoundingClientRect()
|
||||
setPosition({ left: rect.left + rect.width / 2, top: rect.top })
|
||||
|
||||
@@ -169,8 +169,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
||||
(editorPhase === 'structure' &&
|
||||
editorMode === 'build' &&
|
||||
(editorTool === 'door' || editorTool === 'window')) ||
|
||||
(movingNode != null &&
|
||||
!!nodeRegistry.get(movingNode.type)?.capabilities?.wallOpeningPlacement)
|
||||
(movingNode != null && !!nodeRegistry.get(movingNode.type)?.capabilities?.wallOpeningPlacement)
|
||||
// Subscribe to the live-transforms map ref so the layer re-renders
|
||||
// whenever a 3D mover publishes a per-frame position (see
|
||||
// `usePlacementCoordinator`). Without this the 2D floor plan only
|
||||
@@ -376,8 +375,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
||||
const hovered = hoveredId === cid
|
||||
const moving = movingNode?.id === cid
|
||||
const ctx: GeometryContext = {
|
||||
resolve: <N = AnyNode>(rid: AnyNodeId): N | undefined =>
|
||||
nodes[rid] as N | undefined,
|
||||
resolve: <N = AnyNode>(rid: AnyNodeId): N | undefined => nodes[rid] as N | undefined,
|
||||
children: [],
|
||||
siblings: [],
|
||||
parent: activeLevelNode,
|
||||
@@ -391,9 +389,10 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
||||
}
|
||||
: undefined,
|
||||
}
|
||||
const geometry = (
|
||||
builder as (n: AnyNode, c: GeometryContext) => FloorplanGeometry | null
|
||||
)(node, ctx)
|
||||
const geometry = (builder as (n: AnyNode, c: GeometryContext) => FloorplanGeometry | null)(
|
||||
node,
|
||||
ctx,
|
||||
)
|
||||
if (geometry) {
|
||||
const { base, overlay } = splitFloorplanOverlay(geometry)
|
||||
out.push({ id: cid, node, base, overlay, selected, highlighted })
|
||||
@@ -1101,11 +1100,7 @@ function InteractiveGeometry({
|
||||
fill="transparent"
|
||||
onPointerDown={(e) => {
|
||||
if (affordance) {
|
||||
onHandlePointerDown(
|
||||
affordance,
|
||||
payload,
|
||||
e as ReactPointerEvent<SVGGElement>,
|
||||
)
|
||||
onHandlePointerDown(affordance, payload, e as ReactPointerEvent<SVGGElement>)
|
||||
} else {
|
||||
onMoveHandlePointerDown(e as ReactPointerEvent<SVGGElement>)
|
||||
}
|
||||
|
||||
@@ -11,8 +11,8 @@ import { EditorLayoutMobile } from './editor-layout-mobile'
|
||||
const SIDEBAR_MIN_WIDTH = 300
|
||||
const SIDEBAR_MAX_WIDTH = 800
|
||||
const SIDEBAR_COLLAPSE_THRESHOLD = 220
|
||||
// Matches the `w-12` rail in <IconRail>; the resize math is relative to it.
|
||||
const RAIL_WIDTH = 48
|
||||
// Matches the `w-14` rail in <IconRail>; the resize math is relative to it.
|
||||
const RAIL_WIDTH = 56
|
||||
|
||||
// ── Left column: resizable panel with tab bar ────────────────────────────────
|
||||
|
||||
|
||||
@@ -100,9 +100,7 @@ const MENU_Y_OFFSETS: Record<string, number> = {
|
||||
function getMenuYOffset(node: AnyNode | null): number {
|
||||
if (!node) return MENU_Y_OFFSET_DEFAULT + EXTRA_MENU_LIFT
|
||||
if (node.type === 'stair-segment') {
|
||||
return (
|
||||
(MENU_Y_OFFSETS[`stair-${node.segmentType}`] ?? MENU_Y_OFFSET_DEFAULT) + EXTRA_MENU_LIFT
|
||||
)
|
||||
return (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
|
||||
}
|
||||
@@ -181,7 +179,6 @@ export function FloatingActionMenu() {
|
||||
// in-world chrome (height-resize arrows, measurement labels).
|
||||
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
|
||||
// each endpoint that meets an existing wall.
|
||||
const draftWallMeasurement = useMemo(() => {
|
||||
if (!(isWallBuildActive && draftStart && draftEnd && isSegmentLongEnough(draftStart, draftEnd))) {
|
||||
if (
|
||||
!(isWallBuildActive && draftStart && draftEnd && isSegmentLongEnough(draftStart, draftEnd))
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
@@ -237,8 +237,7 @@ export function NodeArrowHandles() {
|
||||
rawNode ? s.overrides.get(rawNode.id) : undefined,
|
||||
)
|
||||
const node = useMemo<AnyNode | null>(
|
||||
() =>
|
||||
rawNode && liveOverride ? ({ ...rawNode, ...liveOverride } as AnyNode) : rawNode,
|
||||
() => (rawNode && liveOverride ? ({ ...rawNode, ...liveOverride } as AnyNode) : rawNode),
|
||||
[rawNode, liveOverride],
|
||||
)
|
||||
|
||||
@@ -251,10 +250,7 @@ export function NodeArrowHandles() {
|
||||
}, [node, def])
|
||||
|
||||
const shouldRender =
|
||||
Boolean(node && descriptors?.length) &&
|
||||
!isFloorplanHovered &&
|
||||
mode !== 'delete' &&
|
||||
!movingNode
|
||||
Boolean(node && descriptors?.length) && !isFloorplanHovered && mode !== 'delete' && !movingNode
|
||||
|
||||
if (!shouldRender || !node || !descriptors) return null
|
||||
return <NodeArrowHandlesForNode descriptors={descriptors} node={node} />
|
||||
@@ -436,7 +432,10 @@ function pickCursor(descriptor: LinearResizeHandle<AnyNode> | RadialResizeHandle
|
||||
}
|
||||
|
||||
function resolveBound(
|
||||
bound: number | ((node: AnyNode, sceneApi: ReturnType<typeof createSceneApi>) => number) | undefined,
|
||||
bound:
|
||||
| number
|
||||
| ((node: AnyNode, sceneApi: ReturnType<typeof createSceneApi>) => number)
|
||||
| undefined,
|
||||
fallback: number,
|
||||
node: AnyNode,
|
||||
sceneApi: ReturnType<typeof createSceneApi>,
|
||||
@@ -572,10 +571,7 @@ function LinearArrow({
|
||||
? intersectionLocal.y
|
||||
: intersectionLocal.z
|
||||
const delta = currentPointer - initialPointer
|
||||
const next = Math.min(
|
||||
maxBound,
|
||||
Math.max(minBound, initialValue + delta * factor),
|
||||
)
|
||||
const next = Math.min(maxBound, Math.max(minBound, initialValue + delta * factor))
|
||||
// apply sees the node-at-drag-start so it can compute anchors from
|
||||
// pre-drag geometry (door-width re-centers on the opposite edge).
|
||||
const patch = descriptor.apply(initialNode as never, next, sceneApi)
|
||||
@@ -928,8 +924,7 @@ function TapActionArrow({
|
||||
const position = descriptor.placement.position(node, placementSceneApi)
|
||||
const rotationY = descriptor.placement.rotationY?.(node, placementSceneApi) ?? 0
|
||||
const shape = descriptor.shape ?? 'arrow'
|
||||
const cursor: Cursor =
|
||||
descriptor.cursor ?? (shape === 'corner-picker' ? 'move' : 'ew-resize')
|
||||
const cursor: Cursor = descriptor.cursor ?? (shape === 'corner-picker' ? 'move' : 'ew-resize')
|
||||
|
||||
const onActivate = (event: ThreeEvent<PointerEvent>) => {
|
||||
event.stopPropagation()
|
||||
|
||||
@@ -658,7 +658,6 @@ function FenceMoveArrowHandle({ fence, handle }: { fence: FenceNode; handle: Wal
|
||||
frustumCulled={false}
|
||||
geometry={arrowGeometry}
|
||||
material={arrowMaterial}
|
||||
|
||||
onPointerDown={activateFenceMove}
|
||||
onPointerEnter={(event) => {
|
||||
event.stopPropagation()
|
||||
|
||||
@@ -514,9 +514,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
||||
position={[x!, editY + height / 2, z!]}
|
||||
>
|
||||
<cylinderGeometry args={[radius, radius, height, 16]} />
|
||||
<meshBasicMaterial
|
||||
color={isDragging ? '#22c55e' : isHovered ? '#60a5fa' : '#3b82f6'}
|
||||
/>
|
||||
<meshBasicMaterial color={isDragging ? '#22c55e' : isHovered ? '#60a5fa' : '#3b82f6'} />
|
||||
</mesh>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -9,15 +9,7 @@ import { cn } from './../../../lib/utils'
|
||||
import useEditor from './../../../store/use-editor'
|
||||
import { ActionButton } from './action-button'
|
||||
|
||||
type ControlId =
|
||||
| 'select'
|
||||
| 'box-select'
|
||||
| 'site-edit'
|
||||
| 'build'
|
||||
| 'material-paint'
|
||||
| 'furnish'
|
||||
| 'zone'
|
||||
| 'delete'
|
||||
type ControlId = 'select' | 'box-select' | 'site-edit' | 'zone' | 'delete'
|
||||
|
||||
type ControlConfig = {
|
||||
id: ControlId
|
||||
@@ -54,30 +46,6 @@ const controls: ControlConfig[] = [
|
||||
color: 'hover:bg-white/5',
|
||||
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',
|
||||
imageSrc: '/icons/zone.png',
|
||||
@@ -104,9 +72,6 @@ export function ControlModes() {
|
||||
const setPhase = useEditor((state) => state.setPhase)
|
||||
const setStructureLayer = useEditor((state) => state.setStructureLayer)
|
||||
const setSelectionTool = useEditor((state) => state.setFloorplanSelectionTool)
|
||||
const primeMaterialPaintFromSelection = useEditor(
|
||||
(state) => state.primeMaterialPaintFromSelection,
|
||||
)
|
||||
const levelId = useViewer((s) => s.selection.levelId)
|
||||
|
||||
// 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 === 'box-select') return mode === 'select' && selectionTool === 'marquee'
|
||||
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')
|
||||
return mode === 'build' && phase === 'structure' && structureLayer === 'zones'
|
||||
return mode === id
|
||||
@@ -168,33 +129,6 @@ export function ControlModes() {
|
||||
} else if (id === 'box-select') {
|
||||
setMode('select')
|
||||
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') {
|
||||
if (getIsActive('zone')) {
|
||||
setMode('select')
|
||||
|
||||
@@ -1,19 +1,14 @@
|
||||
'use client'
|
||||
|
||||
import { useScene } from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { AnimatePresence, motion } from 'motion/react'
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import { MaterialPicker } from './../../../components/ui/controls/material-picker'
|
||||
import { motion } from 'motion/react'
|
||||
import { TooltipProvider } from './../../../components/ui/primitives/tooltip'
|
||||
import { useIsMobile } from './../../../hooks/use-mobile'
|
||||
import { useReducedMotion } from './../../../hooks/use-reduced-motion'
|
||||
import { resolvePaintTargetFromSelection } from './../../../lib/material-paint'
|
||||
import { cn } from './../../../lib/utils'
|
||||
import useEditor from './../../../store/use-editor'
|
||||
import { CameraActions } from './camera-actions'
|
||||
import { ControlModes } from './control-modes'
|
||||
import { StructureTools } from './structure-tools'
|
||||
import { GridSnapControl, SecondaryToggles } from './view-toggles'
|
||||
|
||||
// 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.
|
||||
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 }) {
|
||||
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 hasSelectionOnMobile = useViewer((s) => isMobile && s.selection.selectedIds.length > 0)
|
||||
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),
|
||||
)
|
||||
const reducedMotion = useReducedMotion()
|
||||
const showPaintTray = useMemo(() => mode === 'material-paint', [mode])
|
||||
|
||||
// On mobile, defer the bottom rail to the selection bar when something
|
||||
// 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}
|
||||
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 ? (
|
||||
<div className="flex flex-col items-stretch gap-0.5 px-2 py-1.5">
|
||||
{/* Row 1: control modes only */}
|
||||
|
||||
@@ -1,15 +1,4 @@
|
||||
'use client'
|
||||
|
||||
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'
|
||||
import type { CatalogCategory, StructureTool } from '../../../store/use-editor'
|
||||
|
||||
export type ToolConfig = {
|
||||
id: StructureTool
|
||||
@@ -18,6 +7,10 @@ export type ToolConfig = {
|
||||
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[] = [
|
||||
{ id: 'wall', iconSrc: '/icons/wall.png', label: 'Wall' },
|
||||
{ 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: 'column', iconSrc: '/icons/column.png', label: 'Column' },
|
||||
{ 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: 'ceiling', iconSrc: '/icons/ceiling.png', label: 'Ceiling' },
|
||||
{ id: 'zone', iconSrc: '/icons/zone.png', label: 'Zone' },
|
||||
{ id: 'spawn', iconSrc: '/icons/site.png', label: 'Spawn Point' },
|
||||
{ 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]>(
|
||||
MATERIAL_CATEGORIES[0],
|
||||
)
|
||||
const catalogScrollRef = useRef<HTMLDivElement>(null)
|
||||
const categoryScrollRef = useRef<HTMLDivElement>(null)
|
||||
const catalogItems =
|
||||
selectedCategory === 'other'
|
||||
@@ -73,27 +72,6 @@ export function MaterialPicker({
|
||||
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(() => {
|
||||
const container = categoryScrollRef.current
|
||||
if (!container) return
|
||||
@@ -167,14 +145,12 @@ export function MaterialPicker({
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="w-full max-w-full overflow-x-auto overflow-y-hidden"
|
||||
ref={catalogScrollRef}
|
||||
style={{ msOverflowStyle: 'none', scrollbarWidth: 'none' }}
|
||||
className="grid gap-1.5 pb-1"
|
||||
style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(72px, 1fr))' }}
|
||||
>
|
||||
<div className="flex min-w-max gap-1.5 pb-1">
|
||||
{catalogItems.map((item) => (
|
||||
{catalogItems.map((item) => (
|
||||
<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)
|
||||
? 'border-blue-500 ring-2 ring-blue-500/30'
|
||||
: 'border-gray-300 hover:border-gray-400'
|
||||
@@ -200,7 +176,7 @@ export function MaterialPicker({
|
||||
))}
|
||||
{selectedCategory === 'other' && onChange ? (
|
||||
<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
|
||||
? 'border-blue-500 ring-2 ring-blue-500/30'
|
||||
: 'border-gray-300 hover:border-gray-400'
|
||||
@@ -212,7 +188,6 @@ export function MaterialPicker({
|
||||
Custom
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -2,9 +2,19 @@
|
||||
|
||||
import { ChevronLeft, RotateCcw, X } from 'lucide-react'
|
||||
import Image from 'next/image'
|
||||
import { createContext, useContext } from 'react'
|
||||
import { useIsMobile } from '../../../hooks/use-mobile'
|
||||
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 {
|
||||
title: string
|
||||
/** Either a URL path (legacy panels pass `/icons/floor.png` etc.,
|
||||
@@ -34,6 +44,8 @@ export function PanelWrapper({
|
||||
width = 320, // default width
|
||||
}: PanelWrapperProps) {
|
||||
const isMobile = useIsMobile()
|
||||
const contextFooter = useContext(InspectorFooterContext)
|
||||
const resolvedFooter = footer ?? contextFooter
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -108,7 +120,9 @@ export function PanelWrapper({
|
||||
{/* Content */}
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ import { PanelSection } from '../controls/panel-section'
|
||||
import { SegmentedControl } from '../controls/segmented-control'
|
||||
import { SliderControl } from '../controls/slider-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.
|
||||
@@ -87,10 +87,14 @@ export function ParametricInspector({ footer }: { footer?: React.ReactNode } = {
|
||||
// panel to cover them.
|
||||
if (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 (
|
||||
<Suspense fallback={null}>
|
||||
<CustomPanel />
|
||||
</Suspense>
|
||||
<InspectorFooterContext.Provider value={footer}>
|
||||
<Suspense fallback={null}>
|
||||
<CustomPanel />
|
||||
</Suspense>
|
||||
</InspectorFooterContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import { cn } from './../../../lib/utils'
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../primitives/tooltip'
|
||||
|
||||
export type SidebarTab = {
|
||||
id: string
|
||||
@@ -56,34 +57,37 @@ interface IconRailProps {
|
||||
/**
|
||||
* 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.
|
||||
* 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) {
|
||||
return (
|
||||
<div className="flex h-full w-12 shrink-0 flex-col items-center gap-1 border-border/50 border-r py-2">
|
||||
{tabs.map((tab) => {
|
||||
// While expanded, the active tab is filled. While collapsed, nothing
|
||||
// is "open", so the active tab reads as a muted highlight instead.
|
||||
const isActive = activeTab === tab.id
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'flex h-9 w-9 items-center justify-center rounded-lg transition-colors',
|
||||
isActive && !collapsed
|
||||
? 'bg-accent text-foreground'
|
||||
: isActive
|
||||
? 'text-foreground'
|
||||
: 'text-muted-foreground hover:bg-accent/50 hover:text-foreground',
|
||||
)}
|
||||
key={tab.id}
|
||||
onClick={() => onIconClick(tab.id)}
|
||||
title={tab.label}
|
||||
type="button"
|
||||
>
|
||||
{tab.icon ?? tab.label.charAt(0)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<TooltipProvider delayDuration={0} disableHoverableContent>
|
||||
<div className="flex h-full w-14 shrink-0 flex-col items-center gap-1 border-border/50 border-r py-2">
|
||||
{tabs.map((tab) => {
|
||||
// Only show the active highlight while the panel is open. When
|
||||
// collapsed nothing is "open", so every icon reads as unselected.
|
||||
const showActive = activeTab === tab.id && !collapsed
|
||||
return (
|
||||
<Tooltip key={tab.id}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
className={cn(
|
||||
'group flex h-11 w-11 items-center justify-center rounded-xl transition-all duration-200 [&_img]:transition-[opacity,filter] [&_img]:duration-200',
|
||||
showActive
|
||||
? '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',
|
||||
)}
|
||||
onClick={() => onIconClick(tab.id)}
|
||||
type="button"
|
||||
>
|
||||
{tab.icon ?? tab.label.charAt(0)}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">{tab.label}</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
})}
|
||||
</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'
|
||||
export { useCommandPalette } from './components/ui/command-palette'
|
||||
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 { MetricControl } from './components/ui/controls/metric-control'
|
||||
export { PanelSection } from './components/ui/controls/panel-section'
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
type CeilingNode,
|
||||
resolveLevelId,
|
||||
useLiveNodeOverrides,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { type CeilingNode, resolveLevelId, useLiveNodeOverrides, useScene } from '@pascal-app/core'
|
||||
import { PolygonEditor } from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useCallback, useEffect } from 'react'
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
type CeilingNode,
|
||||
resolveLevelId,
|
||||
useLiveNodeOverrides,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { type CeilingNode, resolveLevelId, useLiveNodeOverrides, useScene } from '@pascal-app/core'
|
||||
import { PolygonEditor } from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useCallback, useEffect } from 'react'
|
||||
|
||||
@@ -154,20 +154,14 @@ function columnBraceHandle(axis: 'x' | 'z'): HandleDescriptor<ColumnNodeType> {
|
||||
axis,
|
||||
anchor: 'center',
|
||||
min: MIN_BRACE_DIMENSION,
|
||||
currentValue: (n) =>
|
||||
axis === 'x' ? (n.braceWidth ?? n.width) : (n.braceDepth ?? n.depth),
|
||||
apply: (_n, newValue) =>
|
||||
axis === 'x' ? { braceWidth: newValue } : { braceDepth: newValue },
|
||||
currentValue: (n) => (axis === 'x' ? (n.braceWidth ?? n.width) : (n.braceDepth ?? n.depth)),
|
||||
apply: (_n, newValue) => (axis === 'x' ? { braceWidth: newValue } : { braceDepth: newValue }),
|
||||
placement: {
|
||||
position: (n) => {
|
||||
// Position outside any splay so the arrow clears the legs.
|
||||
const half =
|
||||
axis === 'x'
|
||||
? Math.max(
|
||||
n.braceBottomSpread ?? 0,
|
||||
n.braceTopSpread ?? 0,
|
||||
n.braceWidth ?? n.width,
|
||||
) / 2
|
||||
? Math.max(n.braceBottomSpread ?? 0, n.braceTopSpread ?? 0, n.braceWidth ?? n.width) / 2
|
||||
: (n.braceDepth ?? n.depth) / 2
|
||||
return axis === 'x'
|
||||
? [half + BRACE_HANDLE_OFFSET, n.height / 2, 0]
|
||||
@@ -205,12 +199,7 @@ function columnFootprintHalf(n: ColumnNodeType): { halfX: number; halfZ: number
|
||||
}
|
||||
return {
|
||||
halfX:
|
||||
Math.max(
|
||||
n.width,
|
||||
n.braceWidth ?? 0,
|
||||
n.braceBottomSpread ?? 0,
|
||||
n.braceTopSpread ?? 0,
|
||||
) / 2,
|
||||
Math.max(n.width, n.braceWidth ?? 0, n.braceBottomSpread ?? 0, n.braceTopSpread ?? 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 initialBraceWidth = node.braceWidth ?? node.width
|
||||
const initialBraceDepth = node.braceDepth ?? node.depth
|
||||
const initialBraceBottomSpread =
|
||||
node.braceBottomSpread ?? Math.max(node.width * 3, 1.2)
|
||||
const initialBraceBottomSpread = node.braceBottomSpread ?? Math.max(node.width * 3, 1.2)
|
||||
const initialBraceTopSpread = node.braceTopSpread ?? 0.12
|
||||
|
||||
let lastPatch: Partial<ColumnNode> = {}
|
||||
@@ -110,10 +109,7 @@ export const columnResizeAffordance: FloorplanAffordance<ColumnNode> = {
|
||||
return
|
||||
case 'brace-top-spread':
|
||||
commitPatch({
|
||||
braceTopSpread: Math.max(
|
||||
MIN_BRACE_TOP_SPREAD,
|
||||
initialBraceTopSpread + 2 * projDelta,
|
||||
),
|
||||
braceTopSpread: Math.max(MIN_BRACE_TOP_SPREAD, initialBraceTopSpread + 2 * projDelta),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -102,8 +102,7 @@ export function buildColumnFloorplan(
|
||||
// outward tip in plan coords. Captured at emit-time so the
|
||||
// affordance doesn't need to recompute `column.rotation` (and
|
||||
// a mid-drag rotation can't drift the projection basis).
|
||||
const outwardLocal: [number, number] =
|
||||
localDirection === 'x' ? [1, 0] : [0, 1]
|
||||
const outwardLocal: [number, number] = localDirection === 'x' ? [1, 0] : [0, 1]
|
||||
const [planAxisX, planAxisY] = rotatePlanVector(outwardLocal[0], outwardLocal[1], rot)
|
||||
children.push({
|
||||
kind: 'move-arrow',
|
||||
|
||||
@@ -355,9 +355,7 @@ export default function ColumnPanel() {
|
||||
// so the preset's braceWidth / braceDepth win over
|
||||
// the carried-from-previous-style values.
|
||||
const stylePreset =
|
||||
option.value === 'vertical'
|
||||
? {}
|
||||
: SUPPORT_STYLE_DEFAULTS[option.value]
|
||||
option.value === 'vertical' ? {} : SUPPORT_STYLE_DEFAULTS[option.value]
|
||||
handleUpdate({
|
||||
supportStyle: option.value,
|
||||
...(option.value !== 'vertical'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {
|
||||
type ElevatorNode as ElevatorNodeType,
|
||||
ElevatorNode as ElevatorNodeSchema,
|
||||
type ElevatorNode as ElevatorNodeType,
|
||||
getElevatorCabDepth,
|
||||
getElevatorCabWidth,
|
||||
getElevatorShaftDepth,
|
||||
@@ -10,11 +10,8 @@ import {
|
||||
type NodeDefinition,
|
||||
resolveElevatorLevels,
|
||||
} from '@pascal-app/core'
|
||||
import {
|
||||
elevatorResizeAffordance,
|
||||
elevatorRotateAffordance,
|
||||
} from './floorplan-affordances'
|
||||
import { buildElevatorFloorplan } from './floorplan'
|
||||
import { elevatorResizeAffordance, elevatorRotateAffordance } from './floorplan-affordances'
|
||||
import { elevatorParametrics } from './parametrics'
|
||||
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: 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,
|
||||
},
|
||||
]
|
||||
for (const side of sides) {
|
||||
const [ox, oz] = rotate(side.local[0], side.local[1])
|
||||
|
||||
@@ -3,15 +3,15 @@ import {
|
||||
getPitchFromActiveRoofHeight,
|
||||
type HandleDescriptor,
|
||||
type NodeDefinition,
|
||||
type RoofSegmentNode as RoofSegmentNodeType,
|
||||
RoofSegmentNode as RoofSegmentNodeSchema,
|
||||
type RoofSegmentNode as RoofSegmentNodeType,
|
||||
} from '@pascal-app/core'
|
||||
import { buildRoofSegmentFloorplan } from './floorplan'
|
||||
import {
|
||||
roofSegmentMoveTarget,
|
||||
roofSegmentResizeAffordance,
|
||||
roofSegmentRotateAffordance,
|
||||
} from './floorplan-affordances'
|
||||
import { buildRoofSegmentFloorplan } from './floorplan'
|
||||
import { roofSegmentParametrics } from './parametrics'
|
||||
import { RoofSegmentNode } from './schema'
|
||||
|
||||
|
||||
@@ -154,11 +154,7 @@ export const roofSegmentMoveTarget: FloorplanMoveTarget<RoofSegmentNode> = ({ no
|
||||
// is `[cosRoof, sinRoof; -sinRoof, cosRoof]`. Used to project world cursor
|
||||
// back into roof-local coords.
|
||||
void roofRot
|
||||
let lastLocal: [number, number, number] = [
|
||||
node.position[0],
|
||||
node.position[1],
|
||||
node.position[2],
|
||||
]
|
||||
let lastLocal: [number, number, number] = [node.position[0], node.position[1], node.position[2]]
|
||||
|
||||
return {
|
||||
affectedIds: [segmentId],
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import type {
|
||||
HandleDescriptor,
|
||||
NodeDefinition,
|
||||
ShelfNode as ShelfNodeType,
|
||||
} from '@pascal-app/core'
|
||||
import { shelfResizeAffordance, shelfRotateAffordance } from './floorplan-affordances'
|
||||
import type { HandleDescriptor, NodeDefinition, ShelfNode as ShelfNodeType } from '@pascal-app/core'
|
||||
import { buildShelfFloorplan } from './floorplan'
|
||||
import { shelfResizeAffordance, shelfRotateAffordance } from './floorplan-affordances'
|
||||
import { shelfFloorplanMoveTarget } from './floorplan-move'
|
||||
import { buildShelfGeometry, shelfRowSurfaceYs } from './geometry'
|
||||
import { shelfParametrics } from './parametrics'
|
||||
|
||||
@@ -24,10 +24,7 @@ const ROTATE_ARROW_CORNER_OFFSET = 0.22
|
||||
* corner. Body move continues to flow through `shelfFloorplanMoveTarget`
|
||||
* (engaged from the action-menu Move button, not from these arrows).
|
||||
*/
|
||||
export function buildShelfFloorplan(
|
||||
node: ShelfNode,
|
||||
ctx?: GeometryContext,
|
||||
): FloorplanGeometry {
|
||||
export function buildShelfFloorplan(node: ShelfNode, ctx?: GeometryContext): FloorplanGeometry {
|
||||
const [px, , pz] = node.position
|
||||
const ry = node.rotation[1] ?? 0
|
||||
// Floor-plan plots at `-ry` so SVG's CW-with-y-down `rotate` direction
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import {
|
||||
type HandleDescriptor,
|
||||
type NodeDefinition,
|
||||
type StairNode as StairNodeType,
|
||||
StairNode as StairNodeSchema,
|
||||
type StairNode as StairNodeType,
|
||||
} from '@pascal-app/core'
|
||||
|
||||
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 yMid = Math.max(n.totalRise ?? 2.5, 0.1) / 2
|
||||
return [
|
||||
width / 2 + STAIR_ROTATE_CORNER_OFFSET,
|
||||
yMid,
|
||||
-STAIR_ROTATE_CORNER_OFFSET,
|
||||
]
|
||||
return [width / 2 + STAIR_ROTATE_CORNER_OFFSET, yMid, -STAIR_ROTATE_CORNER_OFFSET]
|
||||
}
|
||||
|
||||
function stairRotateHandle(): HandleDescriptor<StairNodeType> {
|
||||
@@ -288,6 +284,8 @@ function stairHandles(node: StairNodeType): HandleDescriptor<StairNodeType>[] {
|
||||
handles.push(stairRotateHandle())
|
||||
return handles
|
||||
}
|
||||
|
||||
import { buildStairFloorplan } from './floorplan'
|
||||
import {
|
||||
curvedStairInnerRadiusAffordance,
|
||||
curvedStairSweepAffordance,
|
||||
@@ -296,7 +294,6 @@ import {
|
||||
segmentWidthAffordance,
|
||||
stairRotateAffordance,
|
||||
} from './floorplan-affordances'
|
||||
import { buildStairFloorplan } from './floorplan'
|
||||
import { stairFloorplanMoveTarget } from './floorplan-move'
|
||||
import { stairParametrics } from './parametrics'
|
||||
import { StairNode } from './schema'
|
||||
|
||||
@@ -146,10 +146,7 @@ export const curvedStairWidthAffordance: FloorplanAffordance<StairNode> = {
|
||||
affectedIds: [stairId],
|
||||
apply({ planPoint }) {
|
||||
const currentRadial = (planPoint[0] - cx) * radialX + (planPoint[1] - cz) * radialZ
|
||||
const newWidth = Math.max(
|
||||
MIN_CURVED_WIDTH,
|
||||
initialWidth + (currentRadial - initialRadial),
|
||||
)
|
||||
const newWidth = Math.max(MIN_CURVED_WIDTH, initialWidth + (currentRadial - initialRadial))
|
||||
lastWidth = newWidth
|
||||
useScene.getState().updateNode(stairId, { width: newWidth })
|
||||
},
|
||||
@@ -172,7 +169,9 @@ export const curvedStairInnerRadiusAffordance: FloorplanAffordance<StairNode> =
|
||||
start({ node, initialPlanPoint }) {
|
||||
const stairId = node.id as AnyNodeId
|
||||
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 initialWidth = Math.max(node.width ?? 1, MIN_CURVED_WIDTH)
|
||||
const initialOuterRadius = initialInnerRadius + initialWidth
|
||||
@@ -303,9 +302,7 @@ export const curvedStairSweepAffordance: FloorplanAffordance<StairNode> = {
|
||||
return true
|
||||
},
|
||||
commit() {
|
||||
useScene
|
||||
.getState()
|
||||
.updateNode(stairId, { sweepAngle: lastSweep, rotation: lastRotation })
|
||||
useScene.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
|
||||
// curved-arrow gizmo would sit at the matching world point.
|
||||
const STAIR_ROTATE_PLAN_OFFSET = 0.4
|
||||
|
||||
import {
|
||||
buildFloorplanStairEntry,
|
||||
buildSvgAnnularSectorPath,
|
||||
@@ -134,14 +135,8 @@ export function buildStairFloorplan(
|
||||
// Segment-local +X (width axis) and +Z (run axis) in plan coords,
|
||||
// captured here so the affordance handler can project pointer
|
||||
// deltas without re-walking the stair chain.
|
||||
const axisX: readonly [number, number] = [
|
||||
(c1.x - c0.x) / width,
|
||||
(c1.y - c0.y) / width,
|
||||
]
|
||||
const axisZ: readonly [number, number] = [
|
||||
(c3.x - c0.x) / length,
|
||||
(c3.y - c0.y) / length,
|
||||
]
|
||||
const axisX: readonly [number, number] = [(c1.x - c0.x) / width, (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 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]
|
||||
@@ -277,13 +272,7 @@ export function buildStairFloorplan(
|
||||
// steps past `dashedFromIndex` are dashed.
|
||||
const isEmphasised = stairType === 'spiral' ? isLast : isFirst || isLast
|
||||
const stepWidth =
|
||||
stairType === 'spiral'
|
||||
? isEmphasised
|
||||
? 1.8
|
||||
: 1.15
|
||||
: isEmphasised
|
||||
? 1.5
|
||||
: 1.1
|
||||
stairType === 'spiral' ? (isEmphasised ? 1.8 : 1.15) : isEmphasised ? 1.5 : 1.1
|
||||
children.push({
|
||||
kind: 'line',
|
||||
x1: inner.x,
|
||||
@@ -455,10 +444,7 @@ export function buildStairFloorplan(
|
||||
localZ = -STAIR_ROTATE_PLAN_OFFSET
|
||||
} else {
|
||||
const isSpiral = stairType === 'spiral'
|
||||
const innerR = Math.max(
|
||||
isSpiral ? 0.05 : 0.2,
|
||||
stair.innerRadius ?? (isSpiral ? 0.2 : 0.9),
|
||||
)
|
||||
const innerR = Math.max(isSpiral ? 0.05 : 0.2, stair.innerRadius ?? (isSpiral ? 0.2 : 0.9))
|
||||
const outerR = innerR + (stair.width ?? 1)
|
||||
const sweep = stair.sweepAngle ?? (isSpiral ? Math.PI * 2 : Math.PI / 2)
|
||||
const radius = outerR + STAIR_ROTATE_PLAN_OFFSET
|
||||
|
||||
@@ -651,10 +651,7 @@ function SpiralStepSupportMesh({
|
||||
const sizeX = Math.max(0.04, innerRadius - spiralColumnRadius + 0.04)
|
||||
const sizeY = Math.max(thickness * 0.55, 0.025)
|
||||
const sizeZ = Math.max(0.04, Math.min(0.12, sizeY * 1.5))
|
||||
const geometry = useMemo(
|
||||
() => new THREE.BoxGeometry(sizeX, sizeY, sizeZ),
|
||||
[sizeX, sizeY, sizeZ],
|
||||
)
|
||||
const geometry = useMemo(() => new THREE.BoxGeometry(sizeX, sizeY, sizeZ), [sizeX, sizeY, sizeZ])
|
||||
useEffect(
|
||||
() => () => {
|
||||
geometry.dispose()
|
||||
|
||||
@@ -187,13 +187,15 @@ export const wallFloorplanMoveTarget: FloorplanMoveTarget<WallNode> = ({ node })
|
||||
// until the user commits. Batched into a single zustand
|
||||
// notification — otherwise each per-wall `.set` would re-render
|
||||
// every override subscriber once per linked wall per tick.
|
||||
useLiveNodeOverrides.getState().setMany([
|
||||
[wallId, { start: nextStart, end: nextEnd }],
|
||||
...linkedUpdates.map(
|
||||
(upd) =>
|
||||
[upd.id, { start: upd.start, end: upd.end }] as [string, Record<string, unknown>],
|
||||
),
|
||||
])
|
||||
useLiveNodeOverrides
|
||||
.getState()
|
||||
.setMany([
|
||||
[wallId, { start: nextStart, end: nextEnd }],
|
||||
...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
|
||||
// render dashed outlines of what `commit()` will insert. Mirrors
|
||||
|
||||
@@ -460,9 +460,7 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
|
||||
if (axis) {
|
||||
const originalProj = originalCenter[0] * axis[0] + originalCenter[1] * axis[1]
|
||||
const rawProj = originalProj + rawDeltaX * axis[0] + rawDeltaZ * axis[1]
|
||||
const snappedProj = shiftPressedRef.current
|
||||
? rawProj
|
||||
: snapScalarToGrid(rawProj, snapStep)
|
||||
const snappedProj = shiftPressedRef.current ? rawProj : snapScalarToGrid(rawProj, snapStep)
|
||||
const perpDelta = snappedProj - originalProj
|
||||
deltaX = axis[0] * 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
|
||||
// override writes don't re-render the whole tree.
|
||||
const liveOverride = useLiveNodeOverrides((s) => s.overrides.get(node.id))
|
||||
const overrideRotation = liveOverride?.rotation as
|
||||
| [number, number, number]
|
||||
| number
|
||||
| undefined
|
||||
const overrideRotation = liveOverride?.rotation as [number, number, number] | number | undefined
|
||||
const overridePosition = liveOverride?.position as [number, number, number] | undefined
|
||||
|
||||
useRegistry(node.id, node.type, ref)
|
||||
|
||||
@@ -5,9 +5,9 @@ import {
|
||||
DEFAULT_WALL_HEIGHT,
|
||||
type DoorNode,
|
||||
getAdjacentWallIds,
|
||||
getEffectiveNode,
|
||||
getWallCurveFrameAt,
|
||||
getWallMiterBoundaryPoints,
|
||||
getEffectiveNode,
|
||||
getWallPlanFootprint,
|
||||
getWallSurfacePolygon,
|
||||
getWallThickness,
|
||||
|
||||
Reference in New Issue
Block a user