editor: improve floorplan modes and annotations (#549)
CI / quality (push) Has been cancelled
mcp-ci / ci (push) Has been cancelled

* 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:
Sudhir Yadav
2026-07-27 18:57:59 -04:00
committed by GitHub
co-authored by Claude Opus 4.6
parent daa1f3e99b
commit ab76686b8b
121 changed files with 3535 additions and 7840 deletions
-2
View File
@@ -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">
+16 -3
View File
@@ -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
}
+84 -88
View File
@@ -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 />
</>
)
}
+2 -2
View File
@@ -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"
}