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:
Wassim SAMAD
2026-06-01 13:13:23 -04:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 891e578481
commit 8abfc94b99
40 changed files with 188 additions and 550 deletions
+1 -1
View File
@@ -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'
+1 -5
View File
@@ -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) => (
<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'
@@ -214,7 +190,6 @@ export function MaterialPicker({
) : null}
</div>
</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 (
<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">
<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) => {
// 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
// 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(
'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',
'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',
)}
key={tab.id}
onClick={() => onIconClick(tab.id)}
title={tab.label}
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])
}
+1
View File
@@ -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 -6
View File
@@ -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'
+4 -15
View File
@@ -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
}
+1 -2
View File
@@ -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',
+1 -3
View File
@@ -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'
+2 -5
View File
@@ -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'
+6 -1
View File
@@ -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],
+2 -6
View File
@@ -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'
+1 -4
View File
@@ -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
+4 -7
View File
@@ -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 })
},
}
},
+5 -19
View File
@@ -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
+1 -4
View File
@@ -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()
+3 -1
View File
@@ -187,7 +187,9 @@ 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([
useLiveNodeOverrides
.getState()
.setMany([
[wallId, { start: nextStart, end: nextEnd }],
...linkedUpdates.map(
(upd) =>
+1 -3
View File
@@ -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,