import { cloneLevelSubtree } from '@pascal-app/core/clone-scene-graph' import type { AnyNode, AnyNodeId, LevelNode } from '@pascal-app/core/schema' export type LevelDuplicatePreset = | 'everything' | 'structure' | 'structure-materials' | 'structure-furniture' const NON_DUPLICABLE_NODE_TYPES = new Set(['scan', 'guide', 'spawn']) const STRUCTURAL_NODE_TYPES = new Set([ 'level', 'wall', 'fence', 'zone', 'slab', 'ceiling', 'roof', 'roof-segment', 'stair', 'stair-segment', 'window', 'door', ]) function shouldKeepNode(node: AnyNode, preset: LevelDuplicatePreset) { if (NON_DUPLICABLE_NODE_TYPES.has(node.type)) return false if (preset === 'everything') return true if (preset === 'structure-furniture') return true if (preset === 'structure' || preset === 'structure-materials') { return STRUCTURAL_NODE_TYPES.has(node.type) } return true } /** * Material field keys per kind, used by the `structure` duplicate preset * to strip materials from the cloned subtree. Lookup table replaces the * legacy per-kind switch — the Phase 6 grep gate flagged `case '':` * in this file as the remaining per-kind dispatch outside the registry. * * Future: move this to a `capabilities.materialFields` declaration on * each kind's `NodeDefinition` so adding a new kind with materials is a * registry-only edit. Today the registry doesn't surface material fields * in a uniform way (each kind's panel reads / writes them directly), so * this map mirrors the legacy behavior 1:1. */ const MATERIAL_FIELDS_BY_KIND: Record> = { wall: [ 'material', 'materialPreset', 'interiorMaterial', 'interiorMaterialPreset', 'exteriorMaterial', 'exteriorMaterialPreset', ], slab: ['material', 'materialPreset'], ceiling: ['material', 'materialPreset'], fence: ['material', 'materialPreset'], shelf: ['material', 'materialPreset'], 'roof-segment': ['material', 'materialPreset'], 'stair-segment': ['material', 'materialPreset'], window: ['material', 'materialPreset'], door: ['material', 'materialPreset'], roof: [ 'material', 'materialPreset', 'topMaterial', 'topMaterialPreset', 'edgeMaterial', 'edgeMaterialPreset', 'wallMaterial', 'wallMaterialPreset', ], stair: [ 'material', 'materialPreset', 'railingMaterial', 'railingMaterialPreset', 'treadMaterial', 'treadMaterialPreset', 'sideMaterial', 'sideMaterialPreset', ], } function stripMaterials(node: AnyNode): AnyNode { const fields = MATERIAL_FIELDS_BY_KIND[node.type] if (!fields) return node const next = { ...node } as Record for (const field of fields) delete next[field] return next as AnyNode } function findLevelBuildingId(nodes: Record, levelId: AnyNodeId) { for (const node of Object.values(nodes)) { if (node.type !== 'building' || !('children' in node) || !Array.isArray(node.children)) { continue } if ((node.children as AnyNodeId[]).includes(levelId)) { return node.id as AnyNodeId } } return undefined } export function buildLevelDuplicateCreateOps({ nodes, level, levels, preset, }: { nodes: Record level: LevelNode levels: LevelNode[] preset: LevelDuplicatePreset }) { const { clonedNodes, newLevelId } = cloneLevelSubtree(nodes, level.id) const parentBuildingId = (level.parentId as AnyNodeId | null) ?? findLevelBuildingId(nodes, level.id) const nextLevelNumber = level.level + 1 const shiftedLevels = levels .filter((entry) => entry.id !== level.id && entry.level >= nextLevelNumber) .map((entry) => ({ id: entry.id, level: entry.level + 1, })) const filteredNodes = clonedNodes .filter((node) => shouldKeepNode(node, preset)) .map((node) => (preset === 'structure' ? stripMaterials(node) : node)) const keptIds = new Set(filteredNodes.map((node) => node.id)) const cleanedNodes = filteredNodes.map((node) => { if (!('children' in node && Array.isArray(node.children))) { return node } return { ...node, children: node.children.filter((childId) => keptIds.has(childId as AnyNodeId)), } as AnyNode }) return { createOps: cleanedNodes.map((node) => ({ node: node.id === newLevelId ? ({ ...node, level: nextLevelNumber, } as AnyNode) : node, parentId: node.id === newLevelId ? parentBuildingId : ((node.parentId as AnyNodeId | null) ?? undefined), })), newLevelId, shiftedLevels, } }