refactor: release-review cleanup for roof wall openings

Dual review pass (Claude multi-angle + Codex release-quality). One
correctness fix and the agreed do-now cleanups:

- fix: clone-scene-graph remaps roofSegmentId like wallId in both
  clone paths — duplicated scenes/levels kept pointing roof-hosted
  children at the original segments.
- extract the settled, stateless roof target/cursor math shared by the
  four door/window tools into shared/roof-wall-opening-placement.ts
  (resolveRoofWallOpeningTarget + getRoofWallOpeningCursorPose +
  worldToSelectedBuildingLocal); tools keep the stateful lifecycle
  (drafts, undo/temporal, commit field lists). −199 net lines.
- rename host-generic state: currentWallId→currentHostId,
  markWallDirty→markHostDirty (they hold segment ids too); capability
  cascadesViaHostSegment→dirtyHandledByOwnSystem (behavior-facing,
  before the public API hardens).
- drop getRoofAccessoryKinds from core's public API — its only caller
  was the standalone Build tab, which now enumerates the registry
  inline with its app-specific filter.
- window move-tool uses the shared stripPlacementMetadataFlags; stale
  "segment-local" comment fixed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Aymeric Rabot
2026-06-10 14:33:13 -04:00
co-authored by Claude Fable 5
parent b8dfa90762
commit aa3b0ef758
16 changed files with 349 additions and 435 deletions
+22 -21
View File
@@ -1,6 +1,6 @@
'use client'
import { getRoofAccessoryKinds, nodeRegistry } from '@pascal-app/core'
import { nodeRegistry } from '@pascal-app/core'
import { MaterialPaintPanel, triggerSFX, useEditor } from '@pascal-app/editor'
import Image from 'next/image'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
@@ -87,9 +87,10 @@ 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
* `capabilities.roofAccessory`, enumerated from the registry at render time
* (it is populated by the app bootstrap — a module-scope const would race it)
* 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 {
@@ -116,23 +117,23 @@ export function BuildTab() {
// 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()
// Door / window declare `roofAccessory` for the wall-face cut but
// already have their own Build tiles — listing them here too
// would duplicate the entry under Roof → Features.
.filter((kind) => !nodeRegistry.get(kind)?.capabilities?.wallOpeningPlacement)
.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 roofFeatures = useMemo<RoofFeature[]>(() => {
const features: RoofFeature[] = []
for (const [kind, def] of nodeRegistry.entries()) {
if (def.capabilities.roofAccessory === undefined) continue
// Door / window declare `roofAccessory` for the wall-face cut but
// already have their own Build tiles — listing them here too
// would duplicate the entry under Roof → Features.
if (def.capabilities.wallOpeningPlacement) continue
const icon = def.presentation?.icon
features.push({
kind,
label: def.presentation?.label ?? kind,
iconSrc: icon?.kind === 'url' ? icon.src : ROOF_FEATURE_FALLBACK_ICON,
})
}
return features
}, [])
const isTypeActive = (type: BuildType) =>
type.mode === 'material-paint' ? mode === 'material-paint' : selectedTypeId === type.id