editor: improve floorplan modes and annotations (#549)
* Add roof surface placement support for items Items (e.g. solar panels) can now be placed on sloped roof surfaces. The placement system computes euler rotation from the roof surface normal so items sit flush on the slope instead of going inside. - Add roofStrategy to placement-strategies with enter/move/click/leave - Wire roof:enter/move/click/leave events in the placement coordinator - Add calculateRoofRotation in placement-math using surface normals - Support full 3D cursor rotation for sloped surfaces - Items on roofs are parented to the level with world-space rotation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fixed conflict * fix(editor): anchor floorplan cursor to snapped point * fix(nodes): preview floorplan edits through live overrides * feat(editor): add context-aware floorplan modes * fix(nodes): render crisp wall selection hatching * fix(editor): cap floorplan handles at extreme zoom * fix(editor): keep zone labels upright after rotation * refactor(editor): make referenced annotations registry-driven * refactor(nodes): colocate contextual dimension builders * fix(editor): use mode-driven angle snapping * fix(nodes): use mode-driven move snapping * chore(editor): update react scan tooling * refactor(floorplan): streamline construction documentation * refactor(floorplan): remove wall assembly roadmap * fix(floorplan): migrate retired scene data --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
daa1f3e99b
commit
ab76686b8b
@@ -5,7 +5,6 @@ import { Hammer, Layers, Package, Settings } from 'lucide-react'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { BuildTab } from '@/components/build-tab'
|
||||
import { FloorplanConstructionPreflight } from '@/components/floorplan-construction-preflight'
|
||||
import {
|
||||
CommunityViewerToolbarLeft,
|
||||
CommunityViewerToolbarRight,
|
||||
@@ -90,7 +89,6 @@ const PROJECT_ID = 'local-editor'
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="relative h-screen w-screen">
|
||||
<FloorplanConstructionPreflight />
|
||||
{PROJECT_ID === 'local-editor' && (
|
||||
<div className="pointer-events-none absolute top-3 left-1/2 z-40 -translate-x-1/2">
|
||||
<div className="pointer-events-auto flex items-center gap-3 rounded-full border border-border/60 bg-background/90 px-4 py-1.5 text-xs shadow-sm backdrop-blur">
|
||||
|
||||
@@ -2,10 +2,13 @@
|
||||
|
||||
import { nodeRegistry } from '@pascal-app/core'
|
||||
import {
|
||||
type FloorplanMode,
|
||||
getFloorplanNodeExtension,
|
||||
isFloorplanToolAvailableInMode,
|
||||
MaterialPaintPanel,
|
||||
triggerSFX,
|
||||
useEditor,
|
||||
useFloorplanMode,
|
||||
} from '@pascal-app/editor'
|
||||
import { useLiquidLineToolOptions } from '@pascal-app/nodes'
|
||||
import Image from 'next/image'
|
||||
@@ -73,7 +76,7 @@ const BASE_BUILD_TYPES: BuildType[] = [
|
||||
{ id: 'painting', label: 'Painting', iconSrc: '/icons/paint.webp', mode: 'material-paint' },
|
||||
]
|
||||
|
||||
function collectBuildTypes(): BuildType[] {
|
||||
function collectBuildTypes(floorplanMode: FloorplanMode): BuildType[] {
|
||||
const baseKinds = new Set(BASE_BUILD_TYPES.flatMap((type) => (type.kind ? [type.kind] : [])))
|
||||
const tools = BASE_BUILD_TYPES.filter((type) => type.kind).map((type, index) => ({
|
||||
...type,
|
||||
@@ -86,6 +89,7 @@ function collectBuildTypes(): BuildType[] {
|
||||
if (
|
||||
baseKinds.has(kind) ||
|
||||
!extension?.tool ||
|
||||
!isFloorplanToolAvailableInMode(extension.availableModes, floorplanMode) ||
|
||||
!presentation ||
|
||||
presentation.hidden ||
|
||||
presentation.paletteSection !== 'structure'
|
||||
@@ -126,7 +130,15 @@ const MEP_ITEMS: MepItem[] = [
|
||||
*/
|
||||
function activateBuildTool(kind: string): void {
|
||||
const ed = useEditor.getState()
|
||||
const preferredView = getFloorplanNodeExtension(nodeRegistry.get(kind))?.preferredView
|
||||
const definition = nodeRegistry.get(kind)
|
||||
const extension = getFloorplanNodeExtension(definition)
|
||||
if (
|
||||
!isFloorplanToolAvailableInMode(extension?.availableModes, useFloorplanMode.getState().mode)
|
||||
) {
|
||||
useFloorplanMode.getState().showExpertModeNotice(definition?.presentation?.label ?? kind)
|
||||
return
|
||||
}
|
||||
const preferredView = extension?.preferredView
|
||||
if (preferredView) ed.setViewMode(preferredView)
|
||||
ed.setPhase('structure')
|
||||
ed.setStructureLayer('elements')
|
||||
@@ -184,9 +196,10 @@ const MEP_TOOL_KINDS = new Set<string>([
|
||||
export function BuildTab() {
|
||||
const activeTool = useEditor((s) => s.tool)
|
||||
const mode = useEditor((s) => s.mode)
|
||||
const floorplanMode = useFloorplanMode((s) => s.mode)
|
||||
const follow = useLiquidLineToolOptions((s) => s.follow)
|
||||
const toggleFollow = useLiquidLineToolOptions((s) => s.toggleFollow)
|
||||
const buildTypes = useMemo(collectBuildTypes, [])
|
||||
const buildTypes = useMemo(() => collectBuildTypes(floorplanMode), [floorplanMode])
|
||||
|
||||
// The fitting / follow tools are armed from a segment's panel, not a grid
|
||||
// tile — keep the segment tile lit so the panel (and the way back) stays
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useScene } from '@pascal-app/core'
|
||||
import { useFloorplanPreflight } from '@pascal-app/editor'
|
||||
import {
|
||||
buildClearanceAdvisories,
|
||||
buildConstructionModuleAdvisories,
|
||||
buildDimensionCompletenessAudit,
|
||||
} from '@pascal-app/nodes'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
export function FloorplanConstructionPreflight() {
|
||||
const nodes = useDebouncedSceneNodes()
|
||||
const clearanceChecksEnabled = useFloorplanPreflight((state) => state.clearanceChecksEnabled)
|
||||
const moduleChecksEnabled = useFloorplanPreflight((state) => state.moduleChecksEnabled)
|
||||
const setAuditIssues = useFloorplanPreflight((state) => state.setAuditIssues)
|
||||
|
||||
const issues = useMemo(() => {
|
||||
const completeness = buildDimensionCompletenessAudit(nodes, {
|
||||
includeAutomaticDimensions: true,
|
||||
}).map((issue) => ({
|
||||
id: issue.id,
|
||||
kind: 'dimension-completeness' as const,
|
||||
severity: issue.severity,
|
||||
message: issue.message,
|
||||
}))
|
||||
const clearance = clearanceChecksEnabled
|
||||
? buildClearanceAdvisories(nodes, { includeDisabled: true }).map((issue) => ({
|
||||
id: issue.id,
|
||||
kind: 'clearance-advisory' as const,
|
||||
severity: issue.severity,
|
||||
message: issue.message,
|
||||
}))
|
||||
: []
|
||||
const modules = moduleChecksEnabled
|
||||
? buildConstructionModuleAdvisories(nodes, { includeDisabled: true }).map((issue) => ({
|
||||
id: issue.id,
|
||||
kind: 'module-advisory' as const,
|
||||
severity: issue.severity,
|
||||
message: issue.message,
|
||||
}))
|
||||
: []
|
||||
return [...completeness, ...clearance, ...modules]
|
||||
}, [clearanceChecksEnabled, moduleChecksEnabled, nodes])
|
||||
|
||||
useEffect(() => {
|
||||
setAuditIssues(issues)
|
||||
return () => setAuditIssues([])
|
||||
}, [issues, setAuditIssues])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function useDebouncedSceneNodes() {
|
||||
const [nodes, setNodes] = useState(() => useScene.getState().nodes)
|
||||
|
||||
useEffect(() => {
|
||||
let pending: ReturnType<typeof setTimeout> | undefined
|
||||
const unsubscribe = useScene.subscribe((state) => {
|
||||
if (pending) clearTimeout(pending)
|
||||
pending = setTimeout(() => setNodes(state.nodes), 100)
|
||||
})
|
||||
return () => {
|
||||
if (pending) clearTimeout(pending)
|
||||
unsubscribe()
|
||||
}
|
||||
}, [])
|
||||
|
||||
return nodes
|
||||
}
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { Icon as IconifyIcon } from '@iconify/react'
|
||||
import {
|
||||
DRAWING_TYPE_OPTIONS,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
@@ -11,9 +10,9 @@ import {
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
useDrawingView,
|
||||
useEditor,
|
||||
useFloorplanAnnotationVisibility,
|
||||
useFloorplanMode,
|
||||
useSidebarStore,
|
||||
type ViewMode,
|
||||
} from '@pascal-app/editor'
|
||||
@@ -150,6 +149,19 @@ const FLOORPLAN_ANNOTATION_OPTIONS = [
|
||||
{ id: 'stairAnnotations', name: 'Stair annotations', icon: Footprints },
|
||||
] as const
|
||||
|
||||
const FLOORPLAN_MODE_OPTIONS = [
|
||||
{
|
||||
id: 'default',
|
||||
name: 'Default',
|
||||
detail: 'Clean plan; dimensions appear with selection',
|
||||
},
|
||||
{
|
||||
id: 'expert',
|
||||
name: 'Expert',
|
||||
detail: 'Full documentation and annotation controls',
|
||||
},
|
||||
] as const
|
||||
|
||||
const FLOORPLAN_WALL_DIMENSION_REFERENCE_OPTIONS = [
|
||||
{ id: 'finished-faces', name: 'Finished faces', detail: 'Full wall thickness' },
|
||||
{ id: 'centerline', name: 'Wall centerline', detail: 'Single wall axis' },
|
||||
@@ -188,49 +200,6 @@ function ViewModeControl() {
|
||||
)
|
||||
}
|
||||
|
||||
function DrawingTypeControl() {
|
||||
const viewMode = useEditor((state) => state.viewMode)
|
||||
const drawingType = useDrawingView((state) => state.drawingType)
|
||||
const setDrawingType = useDrawingView((state) => state.setDrawingType)
|
||||
if (viewMode === '3d') return null
|
||||
|
||||
const active =
|
||||
DRAWING_TYPE_OPTIONS.find((option) => option.id === drawingType) ?? DRAWING_TYPE_OPTIONS[0]
|
||||
|
||||
return (
|
||||
<div className={TOOLBAR_CONTAINER}>
|
||||
<DropdownMenu>
|
||||
<ToolbarTooltip label="Select coordinated drawing">
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
aria-label={`Drawing type: ${active.label}`}
|
||||
className="flex items-center gap-1.5 px-2.5 font-medium text-foreground/90 text-xs transition-colors hover:bg-white/8"
|
||||
type="button"
|
||||
>
|
||||
<Layers3 className="h-3.5 w-3.5" />
|
||||
<span>{active.label}</span>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
</ToolbarTooltip>
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
className="w-56 rounded-xl border-border/45 bg-popover/95 backdrop-blur-xl"
|
||||
side="bottom"
|
||||
sideOffset={8}
|
||||
>
|
||||
{DRAWING_TYPE_OPTIONS.map((option) => (
|
||||
<DropdownMenuItem key={option.id} onSelect={() => setDrawingType(option.id)}>
|
||||
<Layers3 className="h-4 w-4" />
|
||||
<span>{option.label}</span>
|
||||
{drawingType === option.id ? <Check className="ml-auto h-4 w-4" /> : null}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CollapseSidebarButton() {
|
||||
const isCollapsed = useSidebarStore((state) => state.isCollapsed)
|
||||
const setIsCollapsed = useSidebarStore((state) => state.setIsCollapsed)
|
||||
@@ -373,6 +342,8 @@ function DisplayMenu() {
|
||||
const setWallDimensionReference = useFloorplanAnnotationVisibility(
|
||||
(state) => state.setWallDimensionReference,
|
||||
)
|
||||
const floorplanMode = useFloorplanMode((state) => state.mode)
|
||||
const setFloorplanMode = useFloorplanMode((state) => state.setMode)
|
||||
|
||||
const activeShading =
|
||||
SHADING_OPTIONS.find((option) => option.id === shading) ?? SHADING_OPTIONS[0]
|
||||
@@ -432,62 +403,88 @@ function DisplayMenu() {
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<Layers3 className="h-4 w-4" />
|
||||
<span>Floor plan annotations</span>
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent className={SUBMENU_CONTENT_CLASS}>
|
||||
{FLOORPLAN_ANNOTATION_OPTIONS.map((option) => {
|
||||
const OptionIcon = option.icon
|
||||
const visible = annotationVisibility[option.id]
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={option.id}
|
||||
onSelect={(e) =>
|
||||
keepOpen(e, () => setAnnotationCategory(option.id, !visible))
|
||||
}
|
||||
>
|
||||
<OptionIcon className="h-4 w-4" />
|
||||
<span>{option.name}</span>
|
||||
{visible ? (
|
||||
<Eye className="ml-auto h-4 w-4 text-foreground" />
|
||||
) : (
|
||||
<EyeOff className="ml-auto h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
})}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<Ruler className="h-4 w-4" />
|
||||
<span>Wall dimensions</span>
|
||||
<span>Floor plan mode</span>
|
||||
<span className="ml-auto text-muted-foreground text-xs">
|
||||
{
|
||||
FLOORPLAN_WALL_DIMENSION_REFERENCE_OPTIONS.find(
|
||||
(option) => option.id === wallDimensionReference,
|
||||
)?.name
|
||||
}
|
||||
{floorplanMode === 'default' ? 'Default' : 'Expert'}
|
||||
</span>
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent className={SUBMENU_CONTENT_CLASS}>
|
||||
{FLOORPLAN_WALL_DIMENSION_REFERENCE_OPTIONS.map((option) => (
|
||||
<DropdownMenuItem
|
||||
key={option.id}
|
||||
onSelect={(event) =>
|
||||
keepOpen(event, () => setWallDimensionReference(option.id))
|
||||
}
|
||||
>
|
||||
{FLOORPLAN_MODE_OPTIONS.map((option) => (
|
||||
<DropdownMenuItem key={option.id} onSelect={() => setFloorplanMode(option.id)}>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-foreground">{option.name}</span>
|
||||
<span className="text-muted-foreground text-xs">{option.detail}</span>
|
||||
</div>
|
||||
{wallDimensionReference === option.id ? (
|
||||
{floorplanMode === option.id ? (
|
||||
<Check className="ml-auto h-4 w-4 text-foreground" />
|
||||
) : null}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
{floorplanMode === 'expert' ? (
|
||||
<>
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<Layers3 className="h-4 w-4" />
|
||||
<span>Floor plan annotations</span>
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent className={SUBMENU_CONTENT_CLASS}>
|
||||
{FLOORPLAN_ANNOTATION_OPTIONS.map((option) => {
|
||||
const OptionIcon = option.icon
|
||||
const visible = annotationVisibility[option.id]
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={option.id}
|
||||
onSelect={(e) =>
|
||||
keepOpen(e, () => setAnnotationCategory(option.id, !visible))
|
||||
}
|
||||
>
|
||||
<OptionIcon className="h-4 w-4" />
|
||||
<span>{option.name}</span>
|
||||
{visible ? (
|
||||
<Eye className="ml-auto h-4 w-4 text-foreground" />
|
||||
) : (
|
||||
<EyeOff className="ml-auto h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
})}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<Ruler className="h-4 w-4" />
|
||||
<span>Wall dimensions</span>
|
||||
<span className="ml-auto text-muted-foreground text-xs">
|
||||
{
|
||||
FLOORPLAN_WALL_DIMENSION_REFERENCE_OPTIONS.find(
|
||||
(option) => option.id === wallDimensionReference,
|
||||
)?.name
|
||||
}
|
||||
</span>
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent className={SUBMENU_CONTENT_CLASS}>
|
||||
{FLOORPLAN_WALL_DIMENSION_REFERENCE_OPTIONS.map((option) => (
|
||||
<DropdownMenuItem
|
||||
key={option.id}
|
||||
onSelect={(event) =>
|
||||
keepOpen(event, () => setWallDimensionReference(option.id))
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-foreground">{option.name}</span>
|
||||
<span className="text-muted-foreground text-xs">{option.detail}</span>
|
||||
</div>
|
||||
{wallDimensionReference === option.id ? (
|
||||
<Check className="ml-auto h-4 w-4 text-foreground" />
|
||||
) : null}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
<DropdownMenuItem onSelect={(e) => keepOpen(e, () => setMagneticSnap(!magneticSnap))}>
|
||||
@@ -694,7 +691,6 @@ export function CommunityViewerToolbarLeft() {
|
||||
<>
|
||||
<CollapseSidebarButton />
|
||||
<ViewModeControl />
|
||||
<DrawingTypeControl />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -42,8 +42,8 @@
|
||||
"@types/react": "19.2.2",
|
||||
"@types/react-dom": "19.2.2",
|
||||
"agentation": "^3.0.2",
|
||||
"react-grab": "^0.1.29",
|
||||
"react-scan": "^0.5.3",
|
||||
"react-grab": "^0.1.50",
|
||||
"react-scan": "^0.5.7",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "6.0.3"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user