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:
Aymeric Rabot
2026-06-09 19:55:53 -04:00
co-authored by Claude Fable 5
parent eb00f9f477
commit 478f0910f7
4 changed files with 162 additions and 25 deletions
+1
View File
@@ -17,6 +17,7 @@ export type {
export {
discoverPlugins,
getHostRefFields,
getRoofAccessoryKinds,
getSelectableKinds,
isDrawnViaTool,
isDrawnViaToolKind,
+22
View File
@@ -114,6 +114,28 @@ export function isRegistrySelectable(kind: string): boolean {
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
* `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 { useFrame } from '@react-three/fiber'
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 { EDITOR_LAYER } from '../../../lib/constants'
import { sfxEmitter } from '../../../lib/sfx-bus'
@@ -407,6 +415,26 @@ export const SiteBoundaryEditor: React.FC = () => {
}
}, [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(() => {
isDraggingSiteBoundaryRef.current = true
setIsDraggingSiteBoundary(true)
@@ -483,25 +511,27 @@ export const SiteBoundaryEditor: React.FC = () => {
if (!showSiteHandles) return null
return (
<PolygonEditor
color={isSiteBoundaryHighlighted ? ARROW_COLOR : '#10b981'}
minVertices={3}
onBeforeVertexDrag={activateSiteEditing}
onDragCommit={() => {
sfxEmitter.emit('sfx:item-place')
exitSiteEditing()
}}
onDragStart={() => sfxEmitter.emit('sfx:item-pick')}
onDragStateChange={handleSiteBoundaryDragChange}
onMidpointHoverChange={setHoveredMidpoint}
onPolygonChange={handlePolygonChange}
onPolygonPreview={handlePolygonPreview}
onVertexHoverChange={setHoveredVertex}
polygon={displayPolygon}
renderMidpointHandle={renderSiteFlagMidpoint}
renderVertexHandle={renderSiteFlagVertex}
showBorderLine={isSiteBoundaryHighlighted}
showMidpointHandles={showSiteHandles}
/>
<group ref={handlesRootRef}>
<PolygonEditor
color={isSiteBoundaryHighlighted ? ARROW_COLOR : '#10b981'}
minVertices={3}
onBeforeVertexDrag={activateSiteEditing}
onDragCommit={() => {
sfxEmitter.emit('sfx:item-place')
exitSiteEditing()
}}
onDragStart={() => sfxEmitter.emit('sfx:item-pick')}
onDragStateChange={handleSiteBoundaryDragChange}
onMidpointHoverChange={setHoveredMidpoint}
onPolygonChange={handlePolygonChange}
onPolygonPreview={handlePolygonPreview}
onVertexHoverChange={setHoveredVertex}
polygon={displayPolygon}
renderMidpointHandle={renderSiteFlagMidpoint}
renderVertexHandle={renderSiteFlagVertex}
showBorderLine={isSiteBoundaryHighlighted}
showMidpointHandles={showSiteHandles}
/>
</group>
)
}