diff --git a/apps/editor/components/build-tab.tsx b/apps/editor/components/build-tab.tsx index 8d25a5eb..330628c3 100644 --- a/apps/editor/components/build-tab.tsx +++ b/apps/editor/components/build-tab.tsx @@ -1,8 +1,9 @@ 'use client' +import { getRoofAccessoryKinds, nodeRegistry } from '@pascal-app/core' import { MaterialPaintPanel, triggerSFX, useEditor } from '@pascal-app/editor' import Image from 'next/image' -import { useCallback, useEffect, useRef } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Tooltip, TooltipContent, @@ -79,6 +80,27 @@ function activatePaintMode(): void { 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[0]) +} + /** * 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 @@ -88,11 +110,27 @@ function activatePaintMode(): void { export function BuildTab() { const activeTool = useEditor((s) => s.tool) 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(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( + () => + 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) => - type.mode === 'material-paint' - ? mode === 'material-paint' - : mode === 'build' && activeTool === type.kind + type.mode === 'material-paint' ? mode === 'material-paint' : selectedTypeId === type.id const handleTypeClick = useCallback((type: BuildType) => { if (type.mode === 'material-paint') { @@ -100,6 +138,7 @@ export function BuildTab() { } else if (type.kind) { activateBuildTool(type.kind) } + setSelectedTypeId(type.id) }, []) // On open, land on the first build tool — parity with the community Build @@ -160,6 +199,51 @@ export function BuildTab() {
+ ) : selectedTypeId === 'roof' && roofFeatures.length > 0 ? ( +
+
Features
+ +
+ {roofFeatures.map((feature) => { + const active = mode === 'build' && activeTool === feature.kind + return ( + + + + + + {feature.label} + + + ) + })} +
+
+
) : null} ) diff --git a/packages/core/src/registry/index.ts b/packages/core/src/registry/index.ts index 8dcfc58f..94bb862a 100644 --- a/packages/core/src/registry/index.ts +++ b/packages/core/src/registry/index.ts @@ -17,6 +17,7 @@ export type { export { discoverPlugins, getHostRefFields, + getRoofAccessoryKinds, getSelectableKinds, isDrawnViaTool, isDrawnViaToolKind, diff --git a/packages/core/src/registry/registry.ts b/packages/core/src/registry/registry.ts index 051176fc..658636f3 100644 --- a/packages/core/src/registry/registry.ts +++ b/packages/core/src/registry/registry.ts @@ -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. diff --git a/packages/editor/src/components/tools/site/site-boundary-editor.tsx b/packages/editor/src/components/tools/site/site-boundary-editor.tsx index 41da633e..a763d9d4 100644 --- a/packages/editor/src/components/tools/site/site-boundary-editor.tsx +++ b/packages/editor/src/components/tools/site/site-boundary-editor.tsx @@ -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(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 ( - { - 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} - /> + + { + 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} + /> + ) }