feat(editor): roof features in standalone Build tab + thumbnail flag fix
- registry: getRoofAccessoryKinds() enumerates kinds declaring the roofAccessory capability, in deterministic builtin-list order (mirrors getSelectableKinds); exported from the registry barrel. - apps/editor build-tab: a "Features" group under the Roof tile, discovered from the registry (no DB) and activating each kind's roof-attach tool — parity with the community editor's roof features. - site-boundary-editor: hide the flag handle group around thumbnail captures (they render on SCENE_LAYER so the thumbnail camera can't layer-filter them), matching handle-arrow.tsx — keeps flags out of preset/snapshot thumbnails. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
eb00f9f477
commit
478f0910f7
@@ -1,8 +1,9 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
import { getRoofAccessoryKinds, nodeRegistry } from '@pascal-app/core'
|
||||||
import { MaterialPaintPanel, triggerSFX, useEditor } from '@pascal-app/editor'
|
import { MaterialPaintPanel, triggerSFX, useEditor } from '@pascal-app/editor'
|
||||||
import Image from 'next/image'
|
import Image from 'next/image'
|
||||||
import { useCallback, useEffect, useRef } from 'react'
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
@@ -79,6 +80,27 @@ function activatePaintMode(): void {
|
|||||||
ed.setMode('material-paint')
|
ed.setMode('material-paint')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type RoofFeature = { kind: string; label: string; iconSrc: string }
|
||||||
|
|
||||||
|
const ROOF_FEATURE_FALLBACK_ICON = '/icons/roof.png'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Roof accessories surfaced under the Roof tile (a "Features" group). Unlike
|
||||||
|
* the community editor these aren't DB presets — each is a registry kind with
|
||||||
|
* `capabilities.roofAccessory`, discovered via `getRoofAccessoryKinds()` and
|
||||||
|
* activated like any structure tool (the kind's tool attaches it to the roof
|
||||||
|
* segment under the cursor). Label + icon come from the registry's
|
||||||
|
* `presentation`; non-url icons fall back to the roof icon.
|
||||||
|
*/
|
||||||
|
function activateRoofFeatureTool(kind: string): void {
|
||||||
|
const ed = useEditor.getState()
|
||||||
|
ed.setPhase('structure')
|
||||||
|
ed.setStructureLayer('elements')
|
||||||
|
ed.setCatalogCategory(null)
|
||||||
|
ed.setMode('build')
|
||||||
|
ed.setTool(kind as Parameters<typeof ed.setTool>[0])
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build tab for the open-source standalone editor — a preset-less replica of
|
* Build tab for the open-source standalone editor — a preset-less replica of
|
||||||
* the community Build sidebar. Clicking a type activates its raw tool, drawn
|
* the community Build sidebar. Clicking a type activates its raw tool, drawn
|
||||||
@@ -88,11 +110,27 @@ function activatePaintMode(): void {
|
|||||||
export function BuildTab() {
|
export function BuildTab() {
|
||||||
const activeTool = useEditor((s) => s.tool)
|
const activeTool = useEditor((s) => s.tool)
|
||||||
const mode = useEditor((s) => s.mode)
|
const mode = useEditor((s) => s.mode)
|
||||||
|
// Which build tile's panel is showing. Roof is the only tile with a panel
|
||||||
|
// (its Features group); others arm a tool and show nothing below.
|
||||||
|
const [selectedTypeId, setSelectedTypeId] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Read at render time (not module scope): the registry is populated by the
|
||||||
|
// app bootstrap, so enumerating earlier would race it and see no kinds.
|
||||||
|
const roofFeatures = useMemo<RoofFeature[]>(
|
||||||
|
() =>
|
||||||
|
getRoofAccessoryKinds().map((kind) => {
|
||||||
|
const icon = nodeRegistry.get(kind)?.presentation?.icon
|
||||||
|
return {
|
||||||
|
kind,
|
||||||
|
label: nodeRegistry.get(kind)?.presentation?.label ?? kind,
|
||||||
|
iconSrc: icon?.kind === 'url' ? icon.src : ROOF_FEATURE_FALLBACK_ICON,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
const isTypeActive = (type: BuildType) =>
|
const isTypeActive = (type: BuildType) =>
|
||||||
type.mode === 'material-paint'
|
type.mode === 'material-paint' ? mode === 'material-paint' : selectedTypeId === type.id
|
||||||
? mode === 'material-paint'
|
|
||||||
: mode === 'build' && activeTool === type.kind
|
|
||||||
|
|
||||||
const handleTypeClick = useCallback((type: BuildType) => {
|
const handleTypeClick = useCallback((type: BuildType) => {
|
||||||
if (type.mode === 'material-paint') {
|
if (type.mode === 'material-paint') {
|
||||||
@@ -100,6 +138,7 @@ export function BuildTab() {
|
|||||||
} else if (type.kind) {
|
} else if (type.kind) {
|
||||||
activateBuildTool(type.kind)
|
activateBuildTool(type.kind)
|
||||||
}
|
}
|
||||||
|
setSelectedTypeId(type.id)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// On open, land on the first build tool — parity with the community Build
|
// On open, land on the first build tool — parity with the community Build
|
||||||
@@ -160,6 +199,51 @@ export function BuildTab() {
|
|||||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||||
<MaterialPaintPanel />
|
<MaterialPaintPanel />
|
||||||
</div>
|
</div>
|
||||||
|
) : selectedTypeId === 'roof' && roofFeatures.length > 0 ? (
|
||||||
|
<div className="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto">
|
||||||
|
<div className="px-0.5 pt-1 font-medium text-muted-foreground text-xs">Features</div>
|
||||||
|
<TooltipProvider delayDuration={0} disableHoverableContent>
|
||||||
|
<div
|
||||||
|
className="grid gap-1.5"
|
||||||
|
style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(56px, 1fr))' }}
|
||||||
|
>
|
||||||
|
{roofFeatures.map((feature) => {
|
||||||
|
const active = mode === 'build' && activeTool === feature.kind
|
||||||
|
return (
|
||||||
|
<Tooltip key={feature.kind}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
className={cn(
|
||||||
|
'group relative flex aspect-square items-center justify-center rounded-xl p-1 transition-all duration-200',
|
||||||
|
active
|
||||||
|
? 'bg-primary/10 ring-1 ring-primary/50'
|
||||||
|
: 'bg-muted/40 opacity-70 grayscale hover:bg-muted hover:opacity-100 hover:grayscale-0',
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
triggerSFX('sfx:menu-click')
|
||||||
|
activateRoofFeatureTool(feature.kind)
|
||||||
|
}}
|
||||||
|
onMouseEnter={() => triggerSFX('sfx:menu-hover')}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
alt={feature.label}
|
||||||
|
className="size-full object-contain transition-transform duration-200 group-hover:scale-110"
|
||||||
|
height={48}
|
||||||
|
src={feature.iconSrc}
|
||||||
|
width={48}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent className="pointer-events-none" side="top">
|
||||||
|
{feature.label}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</TooltipProvider>
|
||||||
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export type {
|
|||||||
export {
|
export {
|
||||||
discoverPlugins,
|
discoverPlugins,
|
||||||
getHostRefFields,
|
getHostRefFields,
|
||||||
|
getRoofAccessoryKinds,
|
||||||
getSelectableKinds,
|
getSelectableKinds,
|
||||||
isDrawnViaTool,
|
isDrawnViaTool,
|
||||||
isDrawnViaToolKind,
|
isDrawnViaToolKind,
|
||||||
|
|||||||
@@ -114,6 +114,28 @@ export function isRegistrySelectable(kind: string): boolean {
|
|||||||
return nodeRegistry.get(kind)?.capabilities.selectable !== undefined
|
return nodeRegistry.get(kind)?.capabilities.selectable !== undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kinds whose definition declares the `roofAccessory` capability — the roof
|
||||||
|
* accessories (dormer, chimney, vents, gutter, …) that mount onto a roof
|
||||||
|
* segment via their own attach tool. Lets host UIs surface a "Features" group
|
||||||
|
* under the roof category without hardcoding the kind list (the standalone
|
||||||
|
* editor's Build tab; the roof inspector's add menu). Returned in builtin
|
||||||
|
* registration order (`packages/nodes/src/index.ts`), which is deterministic.
|
||||||
|
*
|
||||||
|
* Call at render time, not module-import time: the registry is populated by
|
||||||
|
* the host's bootstrap (`loadPlugin`), so a top-level `const` would race it
|
||||||
|
* and see an empty registry.
|
||||||
|
*/
|
||||||
|
export function getRoofAccessoryKinds(): string[] {
|
||||||
|
const result: string[] = []
|
||||||
|
for (const [kind, def] of nodeRegistry.entries()) {
|
||||||
|
if (def.capabilities.roofAccessory !== undefined) {
|
||||||
|
result.push(kind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Kinds whose `def.floorplanScope` matches the requested scope. Used by
|
* Kinds whose `def.floorplanScope` matches the requested scope. Used by
|
||||||
* `FloorplanRegistryLayer` to discover building-scoped kinds (e.g.
|
* `FloorplanRegistryLayer` to discover building-scoped kinds (e.g.
|
||||||
|
|||||||
@@ -3,7 +3,15 @@ import { SCENE_LAYER } from '@pascal-app/viewer'
|
|||||||
import { useGLTF } from '@react-three/drei/core/Gltf'
|
import { useGLTF } from '@react-three/drei/core/Gltf'
|
||||||
import { useFrame } from '@react-three/fiber'
|
import { useFrame } from '@react-three/fiber'
|
||||||
import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { Color, CylinderGeometry, DoubleSide, type Mesh, type Object3D, RingGeometry } from 'three'
|
import {
|
||||||
|
Color,
|
||||||
|
CylinderGeometry,
|
||||||
|
DoubleSide,
|
||||||
|
type Group,
|
||||||
|
type Mesh,
|
||||||
|
type Object3D,
|
||||||
|
RingGeometry,
|
||||||
|
} from 'three'
|
||||||
import { MeshBasicNodeMaterial } from 'three/webgpu'
|
import { MeshBasicNodeMaterial } from 'three/webgpu'
|
||||||
import { EDITOR_LAYER } from '../../../lib/constants'
|
import { EDITOR_LAYER } from '../../../lib/constants'
|
||||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||||
@@ -407,6 +415,26 @@ export const SiteBoundaryEditor: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}, [isSiteEditing, siteId])
|
}, [isSiteEditing, siteId])
|
||||||
|
|
||||||
|
// The flag models render on SCENE_LAYER (scene-depth occlusion), so unlike
|
||||||
|
// EDITOR_LAYER affordances the thumbnail camera can't filter them — hide
|
||||||
|
// them around captures (preset/snapshot/auto-save thumbnails), same as
|
||||||
|
// `handle-arrow.tsx`.
|
||||||
|
const handlesRootRef = useRef<Group>(null)
|
||||||
|
useEffect(() => {
|
||||||
|
const hideForCapture = () => {
|
||||||
|
if (handlesRootRef.current) handlesRootRef.current.visible = false
|
||||||
|
}
|
||||||
|
const restoreAfterCapture = () => {
|
||||||
|
if (handlesRootRef.current) handlesRootRef.current.visible = true
|
||||||
|
}
|
||||||
|
emitter.on('thumbnail:before-capture', hideForCapture)
|
||||||
|
emitter.on('thumbnail:after-capture', restoreAfterCapture)
|
||||||
|
return () => {
|
||||||
|
emitter.off('thumbnail:before-capture', hideForCapture)
|
||||||
|
emitter.off('thumbnail:after-capture', restoreAfterCapture)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
const activateSiteEditing = useCallback(() => {
|
const activateSiteEditing = useCallback(() => {
|
||||||
isDraggingSiteBoundaryRef.current = true
|
isDraggingSiteBoundaryRef.current = true
|
||||||
setIsDraggingSiteBoundary(true)
|
setIsDraggingSiteBoundary(true)
|
||||||
@@ -483,6 +511,7 @@ export const SiteBoundaryEditor: React.FC = () => {
|
|||||||
if (!showSiteHandles) return null
|
if (!showSiteHandles) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<group ref={handlesRootRef}>
|
||||||
<PolygonEditor
|
<PolygonEditor
|
||||||
color={isSiteBoundaryHighlighted ? ARROW_COLOR : '#10b981'}
|
color={isSiteBoundaryHighlighted ? ARROW_COLOR : '#10b981'}
|
||||||
minVertices={3}
|
minVertices={3}
|
||||||
@@ -503,5 +532,6 @@ export const SiteBoundaryEditor: React.FC = () => {
|
|||||||
showBorderLine={isSiteBoundaryHighlighted}
|
showBorderLine={isSiteBoundaryHighlighted}
|
||||||
showMidpointHandles={showSiteHandles}
|
showMidpointHandles={showSiteHandles}
|
||||||
/>
|
/>
|
||||||
|
</group>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user