diff --git a/Editor/Sources/EditorApp/Panels/EditorStatPanel.swift b/Editor/Sources/EditorApp/Panels/EditorStatPanel.swift index 49b8d55b..1fd1bcd3 100644 --- a/Editor/Sources/EditorApp/Panels/EditorStatPanel.swift +++ b/Editor/Sources/EditorApp/Panels/EditorStatPanel.swift @@ -8,28 +8,98 @@ import SceneRuntime struct DeveloperToolsPanel: View { let app: EditorApplication - @State private var selectedTab: DeveloperToolTab = .performance + @State private var selectedTab: DeveloperToolTab = .overview + @State private var selectedFrameSampleIndex: UInt64? var body: some View { StoreScope(app.store) { store in let timingRevision = store.frameTimingRevision let frameStats = store.state.frameStats let frameStatsHistory = store.frameStatsHistory - let renderStats: RenderFrameStats = selectedTab == .render + let needsRenderSnapshot = selectedTab == .overview + || selectedTab == .render + || selectedTab == .particles + let needsParticleSnapshot = selectedTab == .overview + || selectedTab == .particles + let renderStats: RenderFrameStats = needsRenderSnapshot ? app.currentRenderStats() : .init() + let particleStats = needsParticleSnapshot + ? app.currentParticleFrameStats() + : ParticleFrameStatsResource.empty + let particleEventReport = needsParticleSnapshot + ? app.currentParticleSimulationEventApplyReport() + : ParticleSimulationEventApplyReport.empty + let particleScalability = needsParticleSnapshot + ? app.currentParticleScalabilityState() + : ParticleScalabilityStateResource.default + let particleRenderSummary = needsParticleSnapshot + ? app.currentRenderScene().particleSummary + : ParticleRenderSummary() + let selectedGPUSimulationPlan = needsParticleSnapshot + ? app.scene.currentParticleGPUSimulationPlan(for: store.selectedEntityID) + : nil + let selectedModuleIssues = needsParticleSnapshot + ? app.scene.currentParticleModuleValidationIssues(for: store.selectedEntityID) + : [] + let particleSummary = needsParticleSnapshot + ? makeDeveloperParticleDiagnosticSummary(stats: particleStats, + eventReport: particleEventReport, + scalability: particleScalability, + renderSummary: particleRenderSummary, + renderStats: renderStats) + : nil + let particleAuthoringSummary = needsParticleSnapshot + ? makeDeveloperParticleAuthoringDiagnosticSummary( + gpuPlan: selectedGPUSimulationPlan, + moduleIssues: selectedModuleIssues + ) + : nil + let particleHotspots = needsParticleSnapshot + ? makeDeveloperParticleEmitterHotspots(stats: particleStats, + eventReport: particleEventReport) + : [] + let diagnostics = makeDeveloperWorkbenchIssues( + frameStats: frameStats, + frameHistory: frameStatsHistory, + renderStats: renderStats, + particleSummary: particleSummary, + particleAuthoringSummary: particleAuthoringSummary, + particleHotspots: particleHotspots, + selectedEntityID: store.selectedEntityID, + consoleEntries: store.consoleEntries + ) TabView(selection: $selectedTab, tabs: [ - TabItem(L("Performance"), id: DeveloperToolTab.performance) { - PerformanceDiagnosticsView(stats: frameStats, - history: frameStatsHistory, - timingRevision: timingRevision) + TabItem("Overview", id: DeveloperToolTab.overview) { + DeveloperWorkbenchOverview( + issues: diagnostics, + frameStats: frameStats, + frameHistory: frameStatsHistory, + renderStats: renderStats, + particleSummary: particleSummary, + consoleEntries: store.consoleEntries, + onOpenTarget: { target in + selectedTab = target.tab + if let sampleIndex = target.frameSampleIndex { + selectedFrameSampleIndex = sampleIndex + } + } + ) }, - TabItem(L("Render Stats"), id: DeveloperToolTab.render) { - RenderDiagnosticsView(frameStats: frameStats, - renderStats: renderStats) + TabItem("Frame", id: DeveloperToolTab.frame) { + FrameWorkbenchView(stats: frameStats, + history: frameStatsHistory, + timingRevision: timingRevision, + selectedSampleIndex: $selectedFrameSampleIndex, + issues: diagnostics.filter { $0.target.tab == .frame }) + }, + TabItem("Render", id: DeveloperToolTab.render) { + RenderFrameDebuggerView(frameStats: frameStats, + renderStats: renderStats, + issues: diagnostics.filter { $0.target.tab == .render }) }, - TabItem(L("Runtime"), id: DeveloperToolTab.runtime) { + TabItem("State", id: DeveloperToolTab.state) { RuntimeDiagnosticsView(store: store, timingRevision: timingRevision) }, @@ -46,14 +116,321 @@ struct DeveloperToolsPanel: View { } } -private enum DeveloperToolTab: Hashable { - case performance +enum DeveloperToolTab: Hashable { + case overview + case frame case render - case runtime + case state case particles case console } +enum DeveloperDiagnosticSeverity: String, Equatable { + case nominal = "Nominal" + case info = "Info" + case warning = "Warning" + case critical = "Critical" +} + +enum DeveloperDiagnosticScope: String, Equatable { + case frame = "Frame" + case render = "Render" + case particles = "Particles" + case console = "Console" + case state = "State" +} + +struct DeveloperDiagnosticTarget: Equatable { + var tab: DeveloperToolTab + var frameSampleIndex: UInt64? + var label: String +} + +struct DeveloperDiagnosticIssue: Equatable { + var id: String + var severity: DeveloperDiagnosticSeverity + var scope: DeveloperDiagnosticScope + var title: String + var primarySignal: String + var evidence: [String] + var recommendation: String + var target: DeveloperDiagnosticTarget +} + +struct DeveloperDiagnosticCounts: Equatable { + var critical: Int + var warning: Int + var info: Int + var nominal: Int +} + +func makeDeveloperDiagnosticCounts(_ issues: [DeveloperDiagnosticIssue]) -> DeveloperDiagnosticCounts { + DeveloperDiagnosticCounts( + critical: issues.filter { $0.severity == .critical }.count, + warning: issues.filter { $0.severity == .warning }.count, + info: issues.filter { $0.severity == .info }.count, + nominal: issues.filter { $0.severity == .nominal }.count + ) +} + +func makeDeveloperWorkbenchIssues( + frameStats: EditorFrameStats, + frameHistory: [EditorFrameStatsHistorySample], + renderStats: RenderFrameStats, + particleSummary: DeveloperParticleDiagnosticSummary?, + particleAuthoringSummary: DeveloperParticleDiagnosticSummary?, + particleHotspots: [DeveloperParticleEmitterHotspot], + selectedEntityID: UInt64?, + consoleEntries: [EditorConsoleEntry] +) -> [DeveloperDiagnosticIssue] { + var issues: [DeveloperDiagnosticIssue] = [] + let latestSampleIndex = frameHistory.last?.sampleIndex + + if frameStats.isFramePacingDominated { + issues.append(DeveloperDiagnosticIssue( + id: "frame.pacing", + severity: .warning, + scope: .frame, + title: "Frame pacing gap", + primarySignal: "\(formatMs(frameStats.pacingGapMs)) waiting/idle in latest tick", + evidence: [ + "Observed FPS \(formatFPS(frameStats.fps))", + "Work FPS \(formatFPS(frameStats.workFPS))", + "Work \(formatMs(frameStats.workMs))", + ], + recommendation: "Treat this as event-loop or viewport pacing first; rendering work is not the primary explanation until work time rises.", + target: DeveloperDiagnosticTarget(tab: .frame, + frameSampleIndex: latestSampleIndex, + label: "Open Frame") + )) + } else if frameStats.workMs > 33.3 { + issues.append(DeveloperDiagnosticIssue( + id: "frame.work.30fps", + severity: .critical, + scope: .frame, + title: "Frame work exceeds 30 FPS budget", + primarySignal: "Work \(formatMs(frameStats.workMs))", + evidence: [ + "CPU \(formatMs(cpuMs(frameStats)))", + "GPU / present \(formatMs(frameStats.gpuPresentSeconds * 1000))", + "Likely \(bottleneck(frameStats))", + ], + recommendation: "Inspect the selected frame breakdown before changing quality settings; the bottleneck label only points to the first layer.", + target: DeveloperDiagnosticTarget(tab: .frame, + frameSampleIndex: latestSampleIndex, + label: "Open Frame") + )) + } else if frameStats.workMs > 16.7 { + issues.append(DeveloperDiagnosticIssue( + id: "frame.work.60fps", + severity: .warning, + scope: .frame, + title: "Frame work exceeds 60 FPS budget", + primarySignal: "Work \(formatMs(frameStats.workMs))", + evidence: [ + "Headroom @60 \(formatSignedMs(16.7 - frameStats.workMs))", + "Likely \(bottleneck(frameStats))", + ], + recommendation: "Use the frame timeline to find whether this is a one-frame spike or sustained frame pressure.", + target: DeveloperDiagnosticTarget(tab: .frame, + frameSampleIndex: latestSampleIndex, + label: "Open Frame") + )) + } + + if let trend = makeDeveloperFrameTrendSummary(history: frameHistory), + trend.sampleCount >= 8, + trend.p95WorkMs > 16.7 { + issues.append(DeveloperDiagnosticIssue( + id: "frame.trend.p95", + severity: trend.p95WorkMs > 33.3 ? .critical : .warning, + scope: .frame, + title: "Sustained frame-time pressure", + primarySignal: "P95 work \(formatMs(trend.p95WorkMs)) over \(trend.sampleCount) samples", + evidence: [ + "Average work \(formatMs(trend.averageWorkMs))", + "Peak sample #\(trend.peakWorkSampleIndex)", + "Pacing-dominated \(trend.pacingDominatedSamples)/\(trend.sampleCount)", + ], + recommendation: "Open the peak sample and compare it with the latest frame before tuning render or simulation settings.", + target: DeveloperDiagnosticTarget(tab: .frame, + frameSampleIndex: trend.peakWorkSampleIndex, + label: "Open Peak Frame") + )) + } + + if renderStats.cpuEncodeNS > 16_700_000 { + issues.append(DeveloperDiagnosticIssue( + id: "render.encode", + severity: renderStats.cpuEncodeNS > 33_300_000 ? .critical : .warning, + scope: .render, + title: "Render encode exceeds frame budget", + primarySignal: "Encode \(formatNs(renderStats.cpuEncodeNS))", + evidence: [ + "Draw calls \(renderStats.drawCallCount)", + "Passes \(renderStats.passCount)", + "Bundles \(renderStats.renderBundleCount)", + ], + recommendation: "Open the render debugger and inspect pass encode time before reducing scene content broadly.", + target: DeveloperDiagnosticTarget(tab: .render, + frameSampleIndex: nil, + label: "Open Render") + )) + } + + let passBreakdown = makeDeveloperRenderPassBreakdown(renderStats: renderStats) + if let slowPass = passBreakdown.first(where: { $0.encodeNS > 8_000_000 }) { + issues.append(DeveloperDiagnosticIssue( + id: "render.pass.\(slowPass.name)", + severity: slowPass.encodeNS > 16_700_000 ? .critical : .warning, + scope: .render, + title: "\(slowPass.name) pass is expensive", + primarySignal: "Encode \(formatNs(slowPass.encodeNS))", + evidence: [ + "Draw calls \(slowPass.drawCallCount)", + slowPass.signal, + ], + recommendation: slowPass.recommendation, + target: DeveloperDiagnosticTarget(tab: .render, + frameSampleIndex: nil, + label: "Open Render") + )) + } + + if let particleSummary, + particleSummary.severity != .nominal, + particleSummary.severity != .idle { + issues.append(DeveloperDiagnosticIssue( + id: "particles.health.\(particleSummary.status)", + severity: developerDiagnosticSeverity(from: particleSummary.severity), + scope: .particles, + title: particleSummary.status, + primarySignal: particleSummary.primarySignal, + evidence: particleSummary.details, + recommendation: particleSummary.recommendation, + target: DeveloperDiagnosticTarget(tab: .particles, + frameSampleIndex: nil, + label: "Open Particles") + )) + } + + if let particleAuthoringSummary, + selectedEntityID != nil, + particleAuthoringSummary.severity != .nominal, + particleAuthoringSummary.severity != .idle { + issues.append(DeveloperDiagnosticIssue( + id: "particles.authoring.\(particleAuthoringSummary.status)", + severity: developerDiagnosticSeverity(from: particleAuthoringSummary.severity), + scope: .particles, + title: particleAuthoringSummary.status, + primarySignal: particleAuthoringSummary.primarySignal, + evidence: particleAuthoringSummary.details, + recommendation: particleAuthoringSummary.recommendation, + target: DeveloperDiagnosticTarget(tab: .particles, + frameSampleIndex: nil, + label: "Open Particles") + )) + } + + if let hotspot = particleHotspots.first, + hotspot.severity == .critical || hotspot.severity == .warning { + issues.append(DeveloperDiagnosticIssue( + id: "particles.hotspot.\(hotspot.entityID)", + severity: developerDiagnosticSeverity(from: hotspot.severity), + scope: .particles, + title: "Emitter hotspot #\(hotspot.entityID)", + primarySignal: hotspot.primarySignal, + evidence: hotspot.details, + recommendation: hotspot.recommendation, + target: DeveloperDiagnosticTarget(tab: .particles, + frameSampleIndex: nil, + label: "Open Particles") + )) + } + + let errorCount = consoleEntries.filter { $0.severity == .error }.count + let warningCount = consoleEntries.filter { $0.severity == .warning }.count + if errorCount > 0 { + issues.append(DeveloperDiagnosticIssue( + id: "console.errors", + severity: .critical, + scope: .console, + title: "Console errors", + primarySignal: "\(errorCount) error\(errorCount == 1 ? "" : "s") recorded", + evidence: consoleEntries.reversed().filter { $0.severity == .error }.prefix(3).map(\.message), + recommendation: "Open the console and resolve the newest errors before interpreting downstream runtime symptoms.", + target: DeveloperDiagnosticTarget(tab: .console, + frameSampleIndex: nil, + label: "Open Console") + )) + } else if warningCount > 0 { + issues.append(DeveloperDiagnosticIssue( + id: "console.warnings", + severity: .warning, + scope: .console, + title: "Console warnings", + primarySignal: "\(warningCount) warning\(warningCount == 1 ? "" : "s") recorded", + evidence: consoleEntries.reversed().filter { $0.severity == .warning }.prefix(3).map(\.message), + recommendation: "Review the newest warnings and correlate them with the active scene or selected entity.", + target: DeveloperDiagnosticTarget(tab: .console, + frameSampleIndex: nil, + label: "Open Console") + )) + } + + if issues.isEmpty { + issues.append(DeveloperDiagnosticIssue( + id: "workbench.nominal", + severity: .nominal, + scope: .state, + title: "No blocking diagnostics", + primarySignal: "Latest frame, render, particles, and console signals are nominal", + evidence: [ + "Work \(formatMs(frameStats.workMs))", + "Draw calls \(frameStats.drawCallCount)", + "Console entries \(consoleEntries.count)", + ], + recommendation: "Use Frame or Render when validating a specific scene change; Overview will promote regressions as they appear.", + target: DeveloperDiagnosticTarget(tab: .frame, + frameSampleIndex: latestSampleIndex, + label: "Open Frame") + )) + } + + return issues.sorted(by: developerDiagnosticIssuePrecedes) +} + +private func developerDiagnosticSeverity(from severity: DeveloperParticleDiagnosticSeverity) -> DeveloperDiagnosticSeverity { + switch severity { + case .critical: + return .critical + case .warning: + return .warning + case .info: + return .info + case .nominal, .idle: + return .nominal + } +} + +private func developerDiagnosticIssuePrecedes(_ lhs: DeveloperDiagnosticIssue, + _ rhs: DeveloperDiagnosticIssue) -> Bool { + let lhsRank = developerDiagnosticSeverityRank(lhs.severity) + let rhsRank = developerDiagnosticSeverityRank(rhs.severity) + if lhsRank != rhsRank { return lhsRank > rhsRank } + if lhs.scope.rawValue != rhs.scope.rawValue { return lhs.scope.rawValue < rhs.scope.rawValue } + return lhs.id < rhs.id +} + +private func developerDiagnosticSeverityRank(_ severity: DeveloperDiagnosticSeverity) -> Int { + switch severity { + case .critical: 4 + case .warning: 3 + case .info: 2 + case .nominal: 1 + } +} + struct DeveloperFrameTrendSummary: Equatable { var sampleCount: Int var firstSampleIndex: UInt64 @@ -129,143 +506,624 @@ func makeDeveloperFrameTrendSummary( ) } -private struct PerformanceDiagnosticsView: View { - let stats: EditorFrameStats - let history: [EditorFrameStatsHistorySample] - let timingRevision: UInt64 +struct DeveloperRenderPassInspection: Equatable { + var name: String + var drawCallCount: Int + var encodeNS: UInt64 + var signal: String + var recommendation: String +} + +func makeDeveloperRenderPassBreakdown(renderStats: RenderFrameStats) -> [DeveloperRenderPassInspection] { + var passNames: [RenderPassKind] = [] + for pass in renderStats.activePasses { + if !passNames.contains(pass) { + passNames.append(pass) + } + } + for pass in renderStats.passDrawCallCounts.keys.sorted(by: { $0.rawValue < $1.rawValue }) { + if !passNames.contains(pass) { + passNames.append(pass) + } + } + for pass in renderStats.passEncodeNS.keys.sorted(by: { $0.rawValue < $1.rawValue }) { + if !passNames.contains(pass) { + passNames.append(pass) + } + } + + return passNames.map { pass in + let draws = renderStats.passDrawCallCounts[pass] ?? 0 + let encodeNS = renderStats.passEncodeNS[pass] ?? 0 + return DeveloperRenderPassInspection( + name: pass.rawValue, + drawCallCount: draws, + encodeNS: encodeNS, + signal: developerRenderPassSignal(pass: pass, + draws: draws, + encodeNS: encodeNS, + renderStats: renderStats), + recommendation: developerRenderPassRecommendation(pass: pass, + draws: draws, + encodeNS: encodeNS, + renderStats: renderStats) + ) + } + .sorted { + if $0.encodeNS != $1.encodeNS { return $0.encodeNS > $1.encodeNS } + if $0.drawCallCount != $1.drawCallCount { return $0.drawCallCount > $1.drawCallCount } + return $0.name < $1.name + } +} + +private func developerRenderPassSignal(pass: RenderPassKind, + draws: Int, + encodeNS: UInt64, + renderStats: RenderFrameStats) -> String { + if encodeNS > 0 { + return "\(formatNs(encodeNS)) encode, \(draws) draws" + } + if draws > 0 { + return "\(draws) draws" + } + if pass == .particles && renderStats.gpuParticleRenderInstanceCount > 0 { + return "\(renderStats.gpuParticleRenderInstanceCount) GPU particle instances" + } + return "No measured work" +} + +private func developerRenderPassRecommendation(pass: RenderPassKind, + draws: Int, + encodeNS: UInt64, + renderStats: RenderFrameStats) -> String { + if encodeNS > 16_700_000 { + return "Inspect resources and draw submission in this pass before changing global viewport quality." + } + if pass == .particles && renderStats.gpuParticleSortPaddedItemCount > renderStats.gpuParticleSortItemCount { + return "Check particle sort padding, GPU work split, and render-budget skips in the Particles tool." + } + if draws > 1_000 { + return "Look for batching, instancing, or culling opportunities tied to this pass." + } + if draws == 0 { + return "The pass is active but has no submitted draws; verify whether it is required for this frame." + } + return "Pass work is measurable; compare it against adjacent passes before tuning content." +} + +private struct DeveloperWorkbenchOverview: View { + let issues: [DeveloperDiagnosticIssue] + let frameStats: EditorFrameStats + let frameHistory: [EditorFrameStatsHistorySample] + let renderStats: RenderFrameStats + let particleSummary: DeveloperParticleDiagnosticSummary? + let consoleEntries: [EditorConsoleEntry] + let onOpenTarget: (DeveloperDiagnosticTarget) -> Void var body: some View { - let trend = makeDeveloperFrameTrendSummary(history: history) + let counts = makeDeveloperDiagnosticCounts(issues) + let trend = makeDeveloperFrameTrendSummary(history: frameHistory) ScrollView(.vertical) { - if stats.isFramePacingDominated { - FramePacingNotice(stats: stats) - .padding(horizontal: 12, vertical: 10) - } + DeveloperWorkbenchHeader(counts: counts, + primaryIssue: issues.first, + frameStats: frameStats) + .padding(horizontal: 12, vertical: 10) Row(alignment: .top, spacing: 12) { - StatGroup(title: L("Frame")) { - StatRow(label: "Observed FPS", value: formatFPS(stats.fps)) - StatRow(label: "Tick Gap", value: formatMs(stats.frameMs)) - StatRow(label: "Work", value: formatMs(stats.workMs)) - StatRow(label: "Work FPS", value: formatFPS(stats.workFPS)) - StatRow(label: "Pacing Gap", value: formatMs(stats.pacingGapMs)) - StatRow(label: "Sample", value: "#\(timingRevision)") + DeveloperIssueQueue(issues: issues, + title: "Issue Queue", + onOpenTarget: onOpenTarget) + .flex(1.45, shrink: 1) + + DeveloperOperationalContext(frameStats: frameStats, + trend: trend, + renderStats: renderStats, + particleSummary: particleSummary, + consoleEntries: consoleEntries) + .flex(1, shrink: 1) + } + .padding(horizontal: 12, vertical: 0) + + Divider() + .padding(horizontal: 12, vertical: 10) + + DeveloperWorkflowLauncher(onOpenTarget: onOpenTarget) + .padding(horizontal: 12, vertical: 0) + } + } +} + +private struct DeveloperWorkbenchHeader: View { + let counts: DeveloperDiagnosticCounts + let primaryIssue: DeveloperDiagnosticIssue? + let frameStats: EditorFrameStats + + var body: some View { + Column(alignment: .leading, spacing: 8) { + Row(alignment: .center, spacing: 10) { + Text("Developer Workbench") + .font(.bodyStrong) + .foregroundColor(.onSurface) + DeveloperSeverityBadge( + severity: primaryIssue?.severity ?? .nominal, + text: primaryIssue?.severity.rawValue ?? "Nominal" + ) + Spacer(minLength: 0) + Text("Observed \(formatFPS(frameStats.fps)) FPS") + .font(.caption) + .foregroundColor(.onSurfaceMuted) + } + + Text(primaryIssue?.title ?? "No active diagnostics") + .font(.bodyStrong) + .foregroundColor(.onSurface) + Text(primaryIssue?.primarySignal ?? "Latest frame, render, particle, and console signals are nominal.") + .lineLimit(2) + .font(.caption) + .foregroundColor(.onSurfaceMuted) + + Row(alignment: .center, spacing: 8) { + DeveloperMetricPill(label: "Critical", value: "\(counts.critical)", severity: .critical) + DeveloperMetricPill(label: "Warning", value: "\(counts.warning)", severity: .warning) + DeveloperMetricPill(label: "Info", value: "\(counts.info)", severity: .info) + DeveloperMetricPill(label: "Work", value: formatMs(frameStats.workMs), severity: .nominal) + DeveloperMetricPill(label: "Pacing Gap", value: formatMs(frameStats.pacingGapMs), severity: .nominal) + } + } + .padding(horizontal: 12, vertical: 10) + .background(.surfaceSunken) + .cornerRadius(6) + .border(developerDiagnosticBorder(primaryIssue?.severity ?? .nominal), width: 1) + } +} + +private struct DeveloperMetricPill: View { + let label: String + let value: String + let severity: DeveloperDiagnosticSeverity + + var body: some View { + Row(alignment: .center, spacing: 5) { + Text(label) + .font(.caption) + .foregroundColor(.onSurfaceMuted) + Text(value) + .font(.mono) + .foregroundColor(developerDiagnosticForeground(severity)) + } + .padding(horizontal: 8, vertical: 4) + .background(developerDiagnosticBackground(severity)) + .cornerRadius(4) + } +} + +private struct DeveloperIssueQueue: View { + let issues: [DeveloperDiagnosticIssue] + let title: String + let onOpenTarget: (DeveloperDiagnosticTarget) -> Void + + var body: some View { + Column(alignment: .leading, spacing: 6) { + Text(title) + .font(.bodyStrong) + .foregroundColor(.onSurface) + .padding(horizontal: 10, vertical: 8) + + Column(alignment: .leading, spacing: 6) { + for issue in issues { + DeveloperIssueRow(issue: issue, + onOpenTarget: onOpenTarget) } - .flex(1, shrink: 1) + } + .padding(horizontal: 8, vertical: 0) + } + } +} - StatGroup(title: "CPU") { - StatRow(label: "Input", value: formatMs(stats.inputSeconds * 1000)) - StatRow(label: "Simulation", value: formatMs(stats.simulationSeconds * 1000)) - StatRow(label: "Render Prep", value: formatMs(stats.renderPrepareSeconds * 1000)) - StatRow(label: "Render Submit", value: formatMs(stats.renderSubmitSeconds * 1000)) - StatRow(label: "Total", value: formatMs(cpuMs(stats))) +private struct DeveloperIssueRow: View { + let issue: DeveloperDiagnosticIssue + let onOpenTarget: (DeveloperDiagnosticTarget) -> Void + + var body: some View { + Button(action: { onOpenTarget(issue.target) }) { + Column(alignment: .leading, spacing: 5) { + Row(alignment: .center, spacing: 8) { + DeveloperSeverityBadge(severity: issue.severity, + text: issue.severity.rawValue) + Text(issue.scope.rawValue) + .font(.caption) + .foregroundColor(.onSurfaceMuted) + Text(issue.title) + .lineLimit(1) + .font(.bodyStrong) + .foregroundColor(.onSurface) + .flex(1, shrink: 1) + Text(issue.target.label) + .font(.caption) + .foregroundColor(.accent) } - .flex(1, shrink: 1) - StatGroup(title: "GPU") { - StatRow(label: "Present", value: formatMs(stats.gpuPresentSeconds * 1000)) - StatRow(label: "Draw Calls", value: "\(stats.drawCallCount)") - StatRow(label: "Passes", value: "\(stats.passCount)") - StatRow(label: "Bundles", value: "\(stats.renderBundleCount)") + Text(issue.primarySignal) + .lineLimit(2) + .font(.caption) + .foregroundColor(.onSurface) + + if !issue.evidence.isEmpty { + Text(issue.evidence.prefix(3).joined(separator: " | ")) + .lineLimit(2) + .font(.caption) + .foregroundColor(.onSurfaceMuted) } - .flex(1, shrink: 1) + + Text(issue.recommendation) + .lineLimit(2) + .font(.caption) + .foregroundColor(.onSurfaceMuted) } - .padding(horizontal: 12, vertical: 10) + .padding(horizontal: 10, vertical: 8) + .background(.surfaceSunken) + .cornerRadius(6) + .border(developerDiagnosticBorder(issue.severity), width: 1) + } + .buttonStyle(.plain) + } +} - Divider() +private struct DeveloperSeverityBadge: View { + let severity: DeveloperDiagnosticSeverity + let text: String - if let trend { - Row(alignment: .top, spacing: 12) { - StatGroup(title: "Recent Trend") { - StatRow(label: "Samples", value: "\(trend.sampleCount)") - StatRow(label: "Window", value: "#\(trend.firstSampleIndex)-#\(trend.lastSampleIndex)") - StatRow(label: "Avg Observed FPS", value: formatFPS(trend.averageObservedFPS)) - StatRow(label: "Avg Work FPS", value: formatFPS(trend.averageWorkFPS)) - StatRow(label: "Avg Work", value: formatMs(trend.averageWorkMs)) - } - .flex(1, shrink: 1) + var body: some View { + Text(text) + .lineLimit(1) + .font(.caption) + .foregroundColor(developerDiagnosticForeground(severity)) + .padding(horizontal: 7, vertical: 2) + .background(developerDiagnosticBackground(severity)) + .cornerRadius(4) + } +} - StatGroup(title: "Stability") { - StatRow(label: "Pacing Samples", value: "\(trend.pacingDominatedSamples)/\(trend.sampleCount)") - StatRow(label: "Pacing Share", value: formatPercent(trend.pacingDominatedSamples, - trend.sampleCount)) - StatRow(label: "Avg Gap", value: formatMs(trend.averagePacingGapMs)) - StatRow(label: "Max Gap", value: formatMs(trend.maxPacingGapMs)) - StatRow(label: "P95 Work", value: formatMs(trend.p95WorkMs)) - } +private struct DeveloperOperationalContext: View { + let frameStats: EditorFrameStats + let trend: DeveloperFrameTrendSummary? + let renderStats: RenderFrameStats + let particleSummary: DeveloperParticleDiagnosticSummary? + let consoleEntries: [EditorConsoleEntry] + + var body: some View { + Column(alignment: .leading, spacing: 8) { + StatGroup(title: "Current Frame") { + StatRow(label: "Observed FPS", value: formatFPS(frameStats.fps)) + StatRow(label: "Work FPS", value: formatFPS(frameStats.workFPS)) + StatRow(label: "Work", value: formatMs(frameStats.workMs)) + StatRow(label: "Pacing Gap", value: formatMs(frameStats.pacingGapMs)) + StatRow(label: "Likely", value: bottleneck(frameStats)) + } + + StatGroup(title: "Recent Window") { + if let trend { + StatRow(label: "Samples", value: "\(trend.sampleCount)") + StatRow(label: "P95 Work", value: formatMs(trend.p95WorkMs)) + StatRow(label: "Peak Sample", value: "#\(trend.peakWorkSampleIndex)") + StatRow(label: "Pacing Samples", value: "\(trend.pacingDominatedSamples)/\(trend.sampleCount)") + } else { + StatWrappedValue(label: "Window", value: "No frame history samples yet.") + } + } + + StatGroup(title: "Render / Runtime") { + StatRow(label: "Frame", value: renderStats.frameIndex >= 0 ? "\(renderStats.frameIndex)" : "--") + StatRow(label: "Passes", value: "\(renderStats.passCount)") + StatRow(label: "Draw Calls", value: "\(renderStats.drawCallCount)") + StatRow(label: "Encode", value: formatNs(renderStats.cpuEncodeNS)) + StatRow(label: "Console", value: "\(consoleEntries.count)") + } + + StatGroup(title: "Particle Signal") { + if let particleSummary { + StatRow(label: "Status", value: particleSummary.status) + StatWrappedValue(label: "Signal", value: particleSummary.primarySignal) + StatWrappedValue(label: "Action", value: particleSummary.recommendation) + } else { + StatWrappedValue(label: "Status", value: "Particle diagnostics are loaded in Overview or Particles.") + } + } + } + } +} + +private struct DeveloperWorkflowLauncher: View { + let onOpenTarget: (DeveloperDiagnosticTarget) -> Void + + var body: some View { + Row(alignment: .top, spacing: 12) { + DeveloperWorkflowButton(title: "Frame", + signal: "Pick a frame, inspect CPU/GPU/pacing, compare trend.", + target: DeveloperDiagnosticTarget(tab: .frame, + frameSampleIndex: nil, + label: "Open Frame"), + onOpenTarget: onOpenTarget) + DeveloperWorkflowButton(title: "Render", + signal: "Inspect pass cost, draw submission, GPU particle work split.", + target: DeveloperDiagnosticTarget(tab: .render, + frameSampleIndex: nil, + label: "Open Render"), + onOpenTarget: onOpenTarget) + DeveloperWorkflowButton(title: "Particles", + signal: "Rank emitter hotspots, budget drops, GPU fallback, render skips.", + target: DeveloperDiagnosticTarget(tab: .particles, + frameSampleIndex: nil, + label: "Open Particles"), + onOpenTarget: onOpenTarget) + DeveloperWorkflowButton(title: "Console", + signal: "Review errors and warnings as first-class diagnostic inputs.", + target: DeveloperDiagnosticTarget(tab: .console, + frameSampleIndex: nil, + label: "Open Console"), + onOpenTarget: onOpenTarget) + } + } +} + +private struct DeveloperWorkflowButton: View { + let title: String + let signal: String + let target: DeveloperDiagnosticTarget + let onOpenTarget: (DeveloperDiagnosticTarget) -> Void + + var body: some View { + Button(action: { onOpenTarget(target) }) { + Column(alignment: .leading, spacing: 5) { + Text(title) + .font(.bodyStrong) + .foregroundColor(.onSurface) + Text(signal) + .lineLimit(3) + .font(.caption) + .foregroundColor(.onSurfaceMuted) + } + .padding(horizontal: 10, vertical: 8) + .background(.surfaceSunken) + .cornerRadius(6) + .border(.border, width: 1) + } + .buttonStyle(.plain) + .flex(1, shrink: 1) + } +} + +private struct FrameWorkbenchView: View { + let stats: EditorFrameStats + let history: [EditorFrameStatsHistorySample] + let timingRevision: UInt64 + let selectedSampleIndex: Binding + let issues: [DeveloperDiagnosticIssue] + + var body: some View { + let selectedSample = selectedFrameSample(history: history, + selectedSampleIndex: selectedSampleIndex.wrappedValue) + let selectedStats = selectedSample?.stats ?? stats + ScrollView(.vertical) { + Row(alignment: .top, spacing: 12) { + FrameTimelinePanel(history: history, + selectedSampleIndex: selectedSampleIndex) .flex(1, shrink: 1) - StatGroup(title: "Peak Load") { - StatRow(label: "Peak Sample", value: "#\(trend.peakWorkSampleIndex)") - StatRow(label: "Max Work", value: formatMs(trend.maxWorkMs)) - StatRow(label: "Max Draw Calls", value: "\(trend.maxDrawCallCount)") - StatRow(label: "Max Passes", value: "\(trend.maxPassCount)") - StatRow(label: "Max Bundles", value: "\(trend.maxRenderBundleCount)") + FrameInspectionPanel(stats: selectedStats, + sample: selectedSample, + timingRevision: timingRevision, + issues: issues) + .flex(1.3, shrink: 1) + } + .padding(horizontal: 12, vertical: 10) + } + } +} + +private struct FrameTimelinePanel: View { + let history: [EditorFrameStatsHistorySample] + let selectedSampleIndex: Binding + + var body: some View { + Column(alignment: .leading, spacing: 6) { + Row(alignment: .center, spacing: 8) { + Text("Frame Timeline") + .font(.bodyStrong) + .foregroundColor(.onSurface) + Text("\(history.count)") + .font(.caption) + .foregroundColor(.onSurfaceMuted) + } + .padding(horizontal: 10, vertical: 8) + + if history.isEmpty { + StatWrappedValue(label: "Timeline", value: "No frame history samples yet.") + } else { + Column(alignment: .leading, spacing: 4) { + for sample in history.suffix(36).reversed() { + FrameTimelineRow(sample: sample, + isSelected: selectedSampleIndex.wrappedValue == sample.sampleIndex, + onSelect: { selectedSampleIndex.wrappedValue = sample.sampleIndex }) } - .flex(1, shrink: 1) } - .padding(horizontal: 12, vertical: 10) + .padding(horizontal: 8, vertical: 0) + } + } + } +} - Divider() +private struct FrameTimelineRow: View { + let sample: EditorFrameStatsHistorySample + let isSelected: Bool + let onSelect: () -> Void + + var body: some View { + Button(isSelected: isSelected, action: onSelect) { + Row(alignment: .center, spacing: 8) { + Text("#\(sample.sampleIndex)") + .lineLimit(1) + .font(.mono) + .foregroundColor(isSelected ? .accent : .onSurface) + .frame(width: 48) + Text(frameBudgetGlyph(sample.stats)) + .lineLimit(1) + .font(.mono) + .foregroundColor(frameBudgetColor(sample.stats)) + .frame(width: 18) + Text("Work \(formatMs(sample.stats.workMs))") + .lineLimit(1) + .font(.caption) + .foregroundColor(.onSurface) + .flex(1, shrink: 1) + Text("Gap \(formatMs(sample.stats.pacingGapMs))") + .lineLimit(1) + .font(.caption) + .foregroundColor(.onSurfaceMuted) + } + .padding(horizontal: 8, vertical: 4) + .background(isSelected ? .accent.opacity(0.10) : .surfaceSunken) + .cornerRadius(4) + } + .buttonStyle(.plain) + } +} + +private struct FrameInspectionPanel: View { + let stats: EditorFrameStats + let sample: EditorFrameStatsHistorySample? + let timingRevision: UInt64 + let issues: [DeveloperDiagnosticIssue] + + var body: some View { + Column(alignment: .leading, spacing: 8) { + if issues.isEmpty { + StatGroup(title: "Diagnosis") { + StatRow(label: "Likely", value: bottleneck(stats)) + StatWrappedValue(label: "Action", + value: "No frame issue is currently promoted. Use this view to validate specific changes.") + } + } else { + DeveloperIssueQueue(issues: issues, + title: "Frame Issues", + onOpenTarget: { _ in }) } Row(alignment: .top, spacing: 12) { - StatGroup(title: "Budget") { - StatRow(label: "Work @60 FPS", value: budgetStatus(frameMs: stats.workMs, targetMs: 16.7)) - StatRow(label: "Work @30 FPS", value: budgetStatus(frameMs: stats.workMs, targetMs: 33.3)) - StatRow(label: "Work Headroom @60", value: formatSignedMs(16.7 - stats.workMs)) + StatGroup(title: "Selected Frame") { + StatRow(label: "Sample", value: sample.map { "#\($0.sampleIndex)" } ?? "#\(timingRevision)") + StatRow(label: "Frame", value: sample.map { "\($0.frameIndex)" } ?? "--") + StatRow(label: "Observed FPS", value: formatFPS(stats.fps)) + StatRow(label: "Work FPS", value: formatFPS(stats.workFPS)) + StatRow(label: "Tick Gap", value: formatMs(stats.frameMs)) + StatRow(label: "Work", value: formatMs(stats.workMs)) StatRow(label: "Pacing Gap", value: formatMs(stats.pacingGapMs)) } .flex(1, shrink: 1) - StatGroup(title: "Bottleneck") { - StatRow(label: "Likely", value: bottleneck(stats)) + StatGroup(title: "CPU / GPU") { + StatRow(label: "Input", value: formatMs(stats.inputSeconds * 1000)) + StatRow(label: "Simulation", value: formatMs(stats.simulationSeconds * 1000)) + StatRow(label: "Render Prep", value: formatMs(stats.renderPrepareSeconds * 1000)) + StatRow(label: "Render Submit", value: formatMs(stats.renderSubmitSeconds * 1000)) StatRow(label: "CPU Total", value: formatMs(cpuMs(stats))) StatRow(label: "GPU Present", value: formatMs(stats.gpuPresentSeconds * 1000)) - StatRow(label: "Work", value: formatMs(stats.workMs)) - StatRow(label: "Pacing Gap", value: formatMs(stats.pacingGapMs)) } .flex(1, shrink: 1) + } - StatGroup(title: "Render Summary") { - StatRow(label: "Draw / Pass", value: averageDrawsPerPass(stats)) - StatRow(label: "Draw Calls", value: "\(stats.drawCallCount)") - StatRow(label: "Passes", value: "\(stats.passCount)") - StatRow(label: "Bundles", value: "\(stats.renderBundleCount)") - } - .flex(1, shrink: 1) + StatGroup(title: "Render Inventory") { + StatRow(label: "Draw Calls", value: "\(stats.drawCallCount)") + StatRow(label: "Passes", value: "\(stats.passCount)") + StatRow(label: "Bundles", value: "\(stats.renderBundleCount)") + StatRow(label: "Draw / Pass", value: averageDrawsPerPass(stats)) + StatRow(label: "Work @60 FPS", value: budgetStatus(frameMs: stats.workMs, targetMs: 16.7)) + StatRow(label: "Work @30 FPS", value: budgetStatus(frameMs: stats.workMs, targetMs: 33.3)) } - .padding(horizontal: 12, vertical: 10) } } } -private struct RenderDiagnosticsView: View { +private struct RenderFrameDebuggerView: View { let frameStats: EditorFrameStats let renderStats: RenderFrameStats + let issues: [DeveloperDiagnosticIssue] var body: some View { + let passes = makeDeveloperRenderPassBreakdown(renderStats: renderStats) ScrollView(.vertical) { Row(alignment: .top, spacing: 12) { - StatGroup(title: L("Render")) { - StatRow(label: "Frame", value: renderStats.frameIndex >= 0 ? "\(renderStats.frameIndex)" : "--") - StatRow(label: "Settings Gen", value: "\(renderStats.settingsGeneration)") - StatRow(label: "Passes", value: "\(renderStats.passCount)") - StatRow(label: "Draw Calls", value: "\(renderStats.drawCallCount)") - StatRow(label: "Bundles", value: "\(renderStats.renderBundleCount)") - StatRow(label: "Bundle Jobs", value: "\(renderStats.renderBundleParallelJobs)") + Column(alignment: .leading, spacing: 8) { + if !issues.isEmpty { + DeveloperIssueQueue(issues: issues, + title: "Render Issues", + onOpenTarget: { _ in }) + } + RenderPipelineSummary(frameStats: frameStats, + renderStats: renderStats) } .flex(1, shrink: 1) - StatGroup(title: "CPU Encode") { - StatRow(label: "Prepare", value: formatNs(renderStats.cpuPrepareNS)) - StatRow(label: "Encode", value: formatNs(renderStats.cpuEncodeNS)) - StatRow(label: "Submit", value: formatNs(renderStats.cpuSubmitNS)) - StatRow(label: "Total", value: formatNs(renderStats.cpuFrameTotalNS)) - StatRow(label: "Present", value: formatMs(frameStats.gpuPresentSeconds * 1000)) + RenderPassBreakdownView(passes: passes, + renderStats: renderStats) + .flex(1.25, shrink: 1) + } + .padding(horizontal: 12, vertical: 10) + } + } +} + +private struct RenderPipelineSummary: View { + let frameStats: EditorFrameStats + let renderStats: RenderFrameStats + + var body: some View { + Row(alignment: .top, spacing: 12) { + StatGroup(title: "Frame Encode") { + StatRow(label: "Render Frame", value: renderStats.frameIndex >= 0 ? "\(renderStats.frameIndex)" : "--") + StatRow(label: "Prepare", value: formatNs(renderStats.cpuPrepareNS)) + StatRow(label: "Encode", value: formatNs(renderStats.cpuEncodeNS)) + StatRow(label: "Submit", value: formatNs(renderStats.cpuSubmitNS)) + StatRow(label: "Total", value: formatNs(renderStats.cpuFrameTotalNS)) + StatRow(label: "Present", value: formatMs(frameStats.gpuPresentSeconds * 1000)) + } + .flex(1, shrink: 1) + + StatGroup(title: "Scene Work") { + StatRow(label: "Passes", value: "\(renderStats.passCount)") + StatRow(label: "Draw Calls", value: "\(renderStats.drawCallCount)") + StatRow(label: "Bundles", value: "\(renderStats.renderBundleCount)") + StatRow(label: "Bundle Jobs", value: "\(renderStats.renderBundleParallelJobs)") + StatRow(label: "Settings Gen", value: "\(renderStats.settingsGeneration)") + } + .flex(1, shrink: 1) + } + } +} + +private struct RenderPassBreakdownView: View { + let passes: [DeveloperRenderPassInspection] + let renderStats: RenderFrameStats + + var body: some View { + Column(alignment: .leading, spacing: 8) { + Text("Pass Debugger") + .font(.bodyStrong) + .foregroundColor(.onSurface) + .padding(horizontal: 10, vertical: 8) + + if passes.isEmpty { + StatWrappedValue(label: "Passes", value: "No render pass stats have been reported for this frame.") + } else { + Column(alignment: .leading, spacing: 6) { + for pass in passes { + RenderPassInspectionRow(pass: pass) + } } - .flex(1, shrink: 1) + .padding(horizontal: 8, vertical: 0) + } + Row(alignment: .top, spacing: 12) { StatGroup(title: "Shadow") { StatRow(label: "Lights", value: "\(renderStats.shadowedLightCount)") StatRow(label: "Tiles", value: "\(renderStats.shadowTileCount)") @@ -274,49 +1132,57 @@ private struct RenderDiagnosticsView: View { StatRow(label: "Atlas", value: renderStats.shadowAtlasResolution > 0 ? "\(renderStats.shadowAtlasResolution)" : "--") } .flex(1, shrink: 1) - } - .padding(horizontal: 12, vertical: 10) - - Divider() - Row(alignment: .top, spacing: 12) { - StatGroup(title: "Active Passes") { - if renderStats.activePasses.isEmpty { - StatRow(label: "Passes", value: "--") - } else { - StatWrappedValue(label: "Passes", - value: renderStats.activePasses.map(\.rawValue).joined(separator: ", ")) - } - } - .flex(1, shrink: 1) - - StatGroup(title: "Pass Draw Calls") { - if renderStats.passDrawCallCounts.isEmpty { - StatRow(label: "Passes", value: "--") - } else { - for entry in renderStats.passDrawCallCounts.sorted(by: { $0.key.rawValue < $1.key.rawValue }) { - StatRow(label: entry.key.rawValue, value: "\(entry.value)") - } - } + StatGroup(title: "GPU Particles") { + StatRow(label: "Sim Batches", value: "\(renderStats.gpuParticleSimulationBatchCount)") + StatRow(label: "Sim Particles", value: "\(renderStats.gpuParticleSimulationParticleCount)") + StatRow(label: "Render Instances", value: "\(renderStats.gpuParticleRenderInstanceCount)") + StatRow(label: "Indirect Draws", value: "\(renderStats.gpuParticleIndirectDrawCount)") + StatRow(label: "Workgroups", value: "\(gpuParticleWorkgroupTotal(renderStats))") + StatRow(label: "Sort Padding", value: formatPercent( + renderStats.gpuParticleSortPaddedItemCount - renderStats.gpuParticleSortItemCount, + renderStats.gpuParticleSortPaddedItemCount + )) } .flex(1, shrink: 1) } - .padding(horizontal: 12, vertical: 10) + } + } +} - Row(alignment: .top, spacing: 12) { - StatGroup(title: "Pass Encode") { - if renderStats.passEncodeNS.isEmpty { - StatRow(label: "Passes", value: "--") - } else { - for entry in renderStats.passEncodeNS.sorted(by: { $0.key.rawValue < $1.key.rawValue }) { - StatRow(label: entry.key.rawValue, value: formatNs(entry.value)) - } - } - } - .flex(1, shrink: 1) +private struct RenderPassInspectionRow: View { + let pass: DeveloperRenderPassInspection + + var body: some View { + Column(alignment: .leading, spacing: 4) { + Row(alignment: .center, spacing: 8) { + Text(pass.name) + .lineLimit(1) + .font(.bodyStrong) + .foregroundColor(.onSurface) + .flex(1, shrink: 1) + Text(formatNs(pass.encodeNS)) + .lineLimit(1) + .font(.mono) + .foregroundColor(renderPassEncodeColor(pass.encodeNS)) + Text("\(pass.drawCallCount) draws") + .lineLimit(1) + .font(.caption) + .foregroundColor(.onSurfaceMuted) } - .padding(horizontal: 12, vertical: 10) + Text(pass.signal) + .lineLimit(1) + .font(.caption) + .foregroundColor(.onSurface) + Text(pass.recommendation) + .lineLimit(2) + .font(.caption) + .foregroundColor(.onSurfaceMuted) } + .padding(horizontal: 10, vertical: 8) + .background(.surfaceSunken) + .cornerRadius(6) + .border(renderPassEncodeBorder(pass.encodeNS), width: 1) } } @@ -1626,42 +2492,6 @@ private struct StatGroup: View { } } -private struct FramePacingNotice: View { - let stats: EditorFrameStats - - var body: some View { - Row(alignment: .center, spacing: 10) { - Box { EmptyView() } - .frame(width: 8, height: 8) - .background(.warning) - .cornerRadius(4) - - Column(alignment: .leading, spacing: 2) { - Text("Frame pacing gap") - .font(.bodyStrong) - .foregroundColor(.onSurface) - Text("Observed \(formatMs(stats.frameMs)) tick gap; actual work is \(formatMs(stats.workMs)), with \(formatMs(stats.pacingGapMs)) waiting/idle.") - .lineLimit(2) - .font(.caption) - .foregroundColor(.onSurfaceMuted) - } - .flex(1, shrink: 1) - - Text(bottleneck(stats)) - .font(.bodyStrong) - .foregroundColor(.warning) - .padding(horizontal: 8, vertical: 4) - .background(.surface) - .cornerRadius(4) - .border(.warning.opacity(0.25), width: 1) - } - .padding(horizontal: 12, vertical: 10) - .background(.surfaceSunken) - .cornerRadius(6) - .border(.warning.opacity(0.35), width: 1) - } -} - private struct StatRow: View { let label: String let value: String @@ -1855,3 +2685,77 @@ private func averageDrawsPerPass(_ stats: EditorFrameStats) -> String { if average < 10 { return String(format: "%.1f", average) } return String(format: "%.0f", average) } + +private func selectedFrameSample(history: [EditorFrameStatsHistorySample], + selectedSampleIndex: UInt64?) -> EditorFrameStatsHistorySample? { + if let selectedSampleIndex, + let sample = history.first(where: { $0.sampleIndex == selectedSampleIndex }) { + return sample + } + return history.last +} + +private func frameBudgetGlyph(_ stats: EditorFrameStats) -> String { + if stats.isFramePacingDominated { return "P" } + if stats.workMs > 33.3 { return "!" } + if stats.workMs > 16.7 { return "*" } + return "OK" +} + +private func frameBudgetColor(_ stats: EditorFrameStats) -> SemanticColorRef { + if stats.workMs > 33.3 { return .error } + if stats.isFramePacingDominated || stats.workMs > 16.7 { return .warning } + return .success +} + +private func developerDiagnosticForeground(_ severity: DeveloperDiagnosticSeverity) -> SemanticColorRef { + switch severity { + case .nominal: + return .success + case .info: + return .info + case .warning: + return .warning + case .critical: + return .error + } +} + +private func developerDiagnosticBackground(_ severity: DeveloperDiagnosticSeverity) -> SemanticColorRef { + switch severity { + case .nominal: + return .success.opacity(0.12) + case .info: + return .info.opacity(0.12) + case .warning: + return .warning.opacity(0.12) + case .critical: + return .error.opacity(0.12) + } +} + +private func developerDiagnosticBorder(_ severity: DeveloperDiagnosticSeverity) -> SemanticColorRef { + switch severity { + case .nominal: + return .success.opacity(0.25) + case .info: + return .info.opacity(0.25) + case .warning: + return .warning.opacity(0.35) + case .critical: + return .error.opacity(0.40) + } +} + +private func renderPassEncodeColor(_ encodeNS: UInt64) -> SemanticColorRef { + if encodeNS > 16_700_000 { return .error } + if encodeNS > 8_000_000 { return .warning } + if encodeNS > 0 { return .onSurface } + return .onSurfaceMuted +} + +private func renderPassEncodeBorder(_ encodeNS: UInt64) -> SemanticColorRef { + if encodeNS > 16_700_000 { return .error.opacity(0.40) } + if encodeNS > 8_000_000 { return .warning.opacity(0.35) } + return .border +} diff --git a/Editor/Sources/EditorApp/Panels/Inspector/ParticleControls/InspectorParticleControlsView.swift b/Editor/Sources/EditorApp/Panels/Inspector/ParticleControls/InspectorParticleControlsView.swift new file mode 100644 index 00000000..9993e201 --- /dev/null +++ b/Editor/Sources/EditorApp/Panels/Inspector/ParticleControls/InspectorParticleControlsView.swift @@ -0,0 +1,1143 @@ +#if canImport(CoreGraphics) +import CoreGraphics +#endif +import EditorCore +import GuavaUICompose +import GuavaUIRuntime +import RenderBackend +import SceneRuntime + +extension InspectorPanel { + struct InspectorParticleEmissionShapeValue: View { + let binding: Binding + + var body: some View { + EnumField(value: binding, width: 150) { shape in + switch shape { + case .sphere: return L("Sphere") + case .box: return L("Box") + case .cone: return L("Cone") + } + } + } + } + + struct InspectorParticleCollisionModeValue: View { + let binding: Binding + + var body: some View { + EnumField(value: binding, width: 150) { mode in + switch mode { + case .none: return L("None") + case .localPlane: return L("Local Plane") + case .worldPlane: return L("World Plane") + } + } + } + } + + struct InspectorParticleSimulationSpaceValue: View { + let binding: Binding + + var body: some View { + EnumField(value: binding, width: 150) { space in + switch space { + case .local: return L("Local") + case .world: return L("World") + } + } + } + } + + struct InspectorParticleSimulationBackendValue: View { + let binding: Binding + + var body: some View { + EnumField(value: binding, width: 170) { backend in + switch backend { + case .cpu: return L("CPU") + case .gpuIfSupported: return L("GPU Preferred") + case .gpuRequired: return L("GPU Required") + } + } + } + } + + struct InspectorParticleCurveValue: View { + let binding: Binding + var isEnabled: Bool = true + @State private var selectedKeyIndex: Int? = nil + + var body: some View { + Box(direction: .column, alignItems: .stretch, spacing: 7) { + toolbar + if case .keyframes(let keyframes) = binding.wrappedValue { + ParticleCurvePreview(binding: binding, + selectedKeyIndex: $selectedKeyIndex, + isEnabled: isEnabled) + .frame(height: ParticleCurveEditorLayout.previewHeight) + ParticleCurveKeyframeRows(binding: binding, + keyframes: keyframes, + selectedKeyIndex: $selectedKeyIndex, + isEnabled: isEnabled) + } + } + .padding(horizontal: 8, vertical: 8) + .background(.surfaceSunken) + .cornerRadius(6) + .border(.border, width: 1) + .frame(height: fixedHeight) + .clipped() + } + + private var fixedHeight: Float { + if case .keyframes(let keyframes) = binding.wrappedValue { + return ParticleCurveEditorLayout.valueHeight(keyframeCount: keyframes.count) + } + return ParticleCurveEditorLayout.linearValueHeight + } + + private var toolbar: some View { + Row(alignment: .center, spacing: 8) { + Select(selection: binding, + options: options, + isEnabled: isEnabled, + width: 170) + if case .keyframes(let keyframes) = binding.wrappedValue { + Button(L("Add"), isEnabled: isEnabled) { appendKeyframe(to: keyframes) } + .buttonStyle(.secondary) + .frame(width: 52, height: 24) + Button(L("Reset"), isEnabled: isEnabled) { + binding.wrappedValue = .keyframes(Self.defaultKeyframes) + selectedKeyIndex = nil + } + .buttonStyle(.ghost) + .frame(width: 58, height: 24) + } + Spacer(minLength: 0) + } + .frame(height: ParticleCurveEditorLayout.toolbarHeight) + } + + private var options: [SelectOption] { + var values: [SelectOption] = [ + SelectOption(value: .constant(1), label: L("Constant")), + SelectOption(value: .linear, label: L("Linear")), + SelectOption(value: .easeIn, label: L("Ease In")), + SelectOption(value: .easeOut, label: L("Ease Out")), + SelectOption(value: .easeInOut, label: L("Ease In-Out")), + ] + if case .constant(let value) = binding.wrappedValue, value != 1 { + values.append(SelectOption(value: binding.wrappedValue, + label: "\(L("Constant")) \(value)")) + } + if case .keyframes(let keyframes) = binding.wrappedValue { + values.append(SelectOption(value: binding.wrappedValue, + label: "\(L("Keyframes")) (\(keyframes.count))")) + } else { + values.append(SelectOption(value: .keyframes(Self.defaultKeyframes), + label: L("Keyframes"))) + } + return values + } + + private static let defaultKeyframes: [ParticleCurveKeyframe] = [ + ParticleCurveKeyframe(time: 0, value: 0), + ParticleCurveKeyframe(time: 1, value: 1), + ] + + private func appendKeyframe(to keyframes: [ParticleCurveKeyframe]) { + let sorted = keyframes.sortedByTimeStable() + let insert: ParticleCurveKeyframe + if sorted.count >= 2 { + let widestPair = zip(sorted.indices.dropLast(), sorted.indices.dropFirst()) + .max { lhs, rhs in + let leftSpan = sorted[lhs.1].time - sorted[lhs.0].time + let rightSpan = sorted[rhs.1].time - sorted[rhs.0].time + return leftSpan < rightSpan + } + if let pair = widestPair { + let lower = sorted[pair.0] + let upper = sorted[pair.1] + let time = (lower.time + upper.time) * 0.5 + let value = (lower.value + upper.value) * 0.5 + insert = ParticleCurveKeyframe(time: time, value: value) + } else { + insert = ParticleCurveKeyframe(time: 0.5, value: 0.5) + } + } else { + insert = ParticleCurveKeyframe(time: 0.5, value: 0.5) + } + let next = (sorted + [insert]).sortedByTimeStable() + binding.wrappedValue = .keyframes(next) + selectedKeyIndex = next.nearestIndex(to: insert) + } + + } + + struct InspectorParticleBlendModeValue: View { + let binding: Binding + + var body: some View { + EnumField(value: binding, width: 150) { mode in + switch mode { + case .alpha: return L("Alpha") + case .additive: return L("Additive") + } + } + } + } + + struct InspectorParticleRenderAlignmentValue: View { + let binding: Binding + + var body: some View { + EnumField(value: binding, width: 150) { alignment in + switch alignment { + case .billboard: return L("Billboard") + case .velocity: return L("Velocity") + } + } + } + } + + struct InspectorParticleRenderModeValue: View { + let binding: Binding + + var body: some View { + EnumField(value: binding, width: 150) { mode in + switch mode { + case .billboard: return L("Billboard") + case .ribbon: return L("Ribbon") + } + } + } + } + + struct InspectorParticleSortModeValue: View { + let binding: Binding + + var body: some View { + EnumField(value: binding, width: 190) { mode in + switch mode { + case .distanceDescending: return L("Back to Front") + case .distanceAscending: return L("Front to Back") + case .oldestFirst: return L("Oldest First") + case .youngestFirst: return L("Youngest First") + } + } + } + } + + struct InspectorParticleTextureSheetPlaybackModeValue: View { + let binding: Binding + + var body: some View { + EnumField(value: binding, width: 170) { mode in + switch mode { + case .automatic: return L("Auto") + case .lifetime: return L("Lifetime") + case .playOnce: return L("Play Once") + case .loop: return L("Loop") + case .singleFrame: return L("Single Frame") + } + } + } + } + + struct InspectorParticleRenderBoundsModeValue: View { + let binding: Binding + + var body: some View { + EnumField(value: binding, width: 150) { mode in + switch mode { + case .disabled: return L("Disabled") + case .manual: return L("Manual") + case .automatic: return L("Automatic") + } + } + } + } + + struct InspectorParticleForceModeValue: View { + let binding: Binding + + var body: some View { + EnumField(value: binding, width: 150) { mode in + switch mode { + case .none: return L("None") + case .radial: return L("Radial") + case .vortex: return L("Vortex") + } + } + } + } + + struct InspectorParticleVectorFieldModeValue: View { + let binding: Binding + + var body: some View { + EnumField(value: binding, width: 150) { mode in + switch mode { + case .none: return L("None") + case .uniform: return L("Uniform") + case .curl: return L("Curl") + } + } + } + } + + struct InspectorParticleSubEmitterTriggerValue: View { + let binding: Binding + + var body: some View { + EnumField(value: binding, width: 150) { trigger in + switch trigger { + case .none: return L("None") + case .death: return L("Death") + case .collision: return L("Collision") + } + } + } + } + + struct InspectorParticleSubEmittersValue: View { + let binding: Binding<[ParticleSubEmitter]> + + var body: some View { + Box(direction: .column, alignItems: .stretch, spacing: 8) { + Row(alignment: .center, spacing: 8) { + Text("\(binding.wrappedValue.count) \(L("Rules"))") + .font(.caption) + .foregroundColor(.onSurfaceMuted) + Spacer(minLength: 0) + Button(L("Add")) { appendRule() } + .buttonStyle(.secondary) + .frame(width: 56, height: 24) + } + .frame(height: ParticleSubEmitterEditorLayout.toolbarHeight) + + if binding.wrappedValue.isEmpty { + Box(direction: .column, alignItems: .center, justifyContent: .center) { + Text(L("No sub-emitter rules")) + .font(.caption) + .foregroundColor(.onSurfaceMuted) + } + .frame(height: ParticleSubEmitterEditorLayout.emptyHeight) + .background(.surface) + .cornerRadius(6) + .border(.border, width: 1) + } else { + ScrollView(.vertical, + consumePolicy: .always, + scrollbarGutter: .stable) { + Box(direction: .column, alignItems: .stretch, spacing: ParticleSubEmitterEditorLayout.ruleGap) { + for index in binding.wrappedValue.indices { + ParticleSubEmitterRuleCard(binding: binding, index: index) + } + } + } + .frame(height: ParticleSubEmitterEditorLayout.listHeight(ruleCount: binding.wrappedValue.count)) + } + } + .padding(horizontal: 8, vertical: 8) + .background(.surfaceSunken) + .cornerRadius(6) + .border(.border, width: 1) + .frame(height: ParticleSubEmitterEditorLayout.valueHeight(ruleCount: binding.wrappedValue.count)) + .clipped() + } + + private func appendRule() { + var next = binding.wrappedValue + next.append(ParticleSubEmitter(trigger: .death, + burstCount: 8, + probability: 1, + maxDepth: 1, + inheritVelocity: 0.25, + lifetime: 0.45, + startVelocity: SIMD3(0, 1.5, 0), + velocityRandomness: SIMD3(0.5, 0.5, 0.5), + startSize: 0.25, + endSize: 0, + startColor: SIMD4(1, 1, 1, 1), + endColor: SIMD4(1, 1, 1, 0))) + binding.wrappedValue = next + } + } + + private struct ParticleSubEmitterRuleCard: View { + let binding: Binding<[ParticleSubEmitter]> + let index: Int + + var body: some View { + Box(direction: .column, alignItems: .stretch, spacing: 7) { + Row(alignment: .center, spacing: 8) { + Text("#\(index + 1)") + .font(.bodyStrong) + .foregroundColor(.onSurface) + Spacer(minLength: 0) + Button(icon: .resource(UICommonIcons.close), + size: 10, + tooltip: L("Remove rule"), + action: removeRule) + .buttonStyle(.ghost) + .frame(width: 24, height: 24) + } + .frame(height: 24) + + Row(alignment: .center, spacing: 8) { + labeledCompactField(L("Trigger")) { + EnumField(value: triggerBinding, width: 112) { trigger in + switch trigger { + case .none: return L("None") + case .death: return L("Death") + case .collision: return L("Collision") + } + } + } + labeledCompactField(L("Count")) { + NumberField(value: intBinding(\.burstCount), + decimals: 0, + size: .small, + minValue: 0, + maxValue: 10_000, + step: 1, + showsStepper: true) + } + labeledCompactField(L("Chance")) { + NumberField(value: floatBinding(\.probability), + decimals: 2, + size: .small, + minValue: 0, + maxValue: 1, + step: 0.05, + showsStepper: true) + } + } + + Row(alignment: .center, spacing: 8) { + labeledCompactField(L("Depth")) { + NumberField(value: intBinding(\.maxDepth), + decimals: 0, + size: .small, + minValue: 0, + maxValue: 16, + step: 1, + showsStepper: true) + } + labeledCompactField(L("Inherit")) { + NumberField(value: floatBinding(\.inheritVelocity), + decimals: 2, + size: .small, + minValue: 0, + maxValue: 10, + step: 0.05, + showsStepper: true) + } + labeledCompactField(L("Life")) { + NumberField(value: floatBinding(\.lifetime), + decimals: 2, + size: .small, + minValue: 0.0001, + maxValue: 60, + step: 0.05, + showsStepper: true) + } + } + + labeledWideField(L("Velocity")) { + Vec3Field(x: vectorBinding(\.startVelocity, axis: 0), + y: vectorBinding(\.startVelocity, axis: 1), + z: vectorBinding(\.startVelocity, axis: 2), + decimals: 2, + size: .small) + } + labeledWideField(L("Random")) { + Vec3Field(x: vectorBinding(\.velocityRandomness, axis: 0), + y: vectorBinding(\.velocityRandomness, axis: 1), + z: vectorBinding(\.velocityRandomness, axis: 2), + decimals: 2, + size: .small) + } + + Row(alignment: .center, spacing: 8) { + labeledCompactField(L("Start Size")) { + NumberField(value: floatBinding(\.startSize), + decimals: 2, + size: .small, + minValue: 0, + maxValue: 100, + step: 0.05, + showsStepper: true) + } + labeledCompactField(L("End Size")) { + NumberField(value: floatBinding(\.endSize), + decimals: 2, + size: .small, + minValue: 0, + maxValue: 100, + step: 0.05, + showsStepper: true) + } + } + + Row(alignment: .center, spacing: 8) { + labeledColorField(L("Start Color")) { + ColorField(color: colorBinding(isStart: true), + showAlpha: true, + showsInlineValues: false) + } + labeledColorField(L("End Color")) { + ColorField(color: colorBinding(isStart: false), + showAlpha: true, + showsInlineValues: false) + } + } + } + .padding(horizontal: 8, vertical: 8) + .background(.surface) + .cornerRadius(6) + .border(.border, width: 1) + .frame(height: ParticleSubEmitterEditorLayout.ruleHeight) + .clipped() + } + + private func labeledCompactField(_ title: String, + @ViewBuilder content: () -> Content) -> some View { + Box(direction: .column, alignItems: .stretch, spacing: 3) { + Text(title) + .lineLimit(1) + .font(.caption) + .foregroundColor(.onSurfaceMuted) + content() + .frame(height: 24) + .clipped() + } + .flex(1, shrink: 1, basis: 0) + } + + private func labeledWideField(_ title: String, + @ViewBuilder content: () -> Content) -> some View { + Row(alignment: .center, spacing: 8) { + Text(title) + .lineLimit(1) + .font(.caption) + .foregroundColor(.onSurfaceMuted) + .frame(width: 66) + content() + .flex(1, shrink: 1, basis: 0) + .clipped() + } + .frame(height: 28) + } + + private func labeledColorField(_ title: String, + @ViewBuilder content: () -> Content) -> some View { + Box(direction: .column, alignItems: .stretch, spacing: 3) { + Text(title) + .lineLimit(1) + .font(.caption) + .foregroundColor(.onSurfaceMuted) + content() + .frame(height: 26) + .clipped() + } + .flex(1, shrink: 1, basis: 0) + } + + private var rule: ParticleSubEmitter? { + guard binding.wrappedValue.indices.contains(index) else { return nil } + return binding.wrappedValue[index] + } + + private var triggerBinding: Binding { + Binding( + get: { rule?.trigger ?? .none }, + set: { value in updateRule { $0.trigger = value } } + ) + } + + private func floatBinding(_ keyPath: WritableKeyPath) -> Binding { + Binding( + get: { rule?[keyPath: keyPath] ?? 0 }, + set: { value in updateRule { $0[keyPath: keyPath] = value } } + ) + } + + private func intBinding(_ keyPath: WritableKeyPath) -> Binding { + Binding( + get: { Float(rule?[keyPath: keyPath] ?? 0) }, + set: { value in updateRule { $0[keyPath: keyPath] = Int(value.rounded()) } } + ) + } + + private func vectorBinding(_ keyPath: WritableKeyPath>, + axis: Int) -> Binding { + Binding( + get: { + guard let rule, axis >= 0 && axis < 3 else { return 0 } + return rule[keyPath: keyPath][axis] + }, + set: { value in + guard axis >= 0 && axis < 3 else { return } + updateRule { $0[keyPath: keyPath][axis] = value } + } + ) + } + + private func colorBinding(isStart: Bool) -> Binding { + Binding( + get: { + let color = rule.map { isStart ? $0.startColor : $0.endColor } + ?? SIMD4(1, 1, 1, 1) + return Color(r: color.x, g: color.y, b: color.z, a: color.w) + }, + set: { value in + let color = SIMD4(clamp01(value.r), + clamp01(value.g), + clamp01(value.b), + clamp01(value.a)) + updateRule { + if isStart { + $0.startColor = color + } else { + $0.endColor = color + } + } + } + ) + } + + private func updateRule(_ mutate: (inout ParticleSubEmitter) -> Void) { + guard binding.wrappedValue.indices.contains(index) else { return } + var next = binding.wrappedValue + mutate(&next[index]) + next[index] = sanitized(next[index]) + binding.wrappedValue = next + } + + private func removeRule() { + guard binding.wrappedValue.indices.contains(index) else { return } + var next = binding.wrappedValue + next.remove(at: index) + binding.wrappedValue = next + } + + private func sanitized(_ rule: ParticleSubEmitter) -> ParticleSubEmitter { + ParticleSubEmitter(trigger: rule.trigger, + burstCount: rule.burstCount, + probability: rule.probability, + maxDepth: rule.maxDepth, + inheritVelocity: rule.inheritVelocity, + lifetime: rule.lifetime, + startVelocity: rule.startVelocity, + velocityRandomness: rule.velocityRandomness, + startSize: rule.startSize, + endSize: rule.endSize, + startColor: rule.startColor, + endColor: rule.endColor) + } + + private func clamp01(_ value: Float) -> Float { + max(0, min(1, value)) + } + } + +} + +enum ParticleCurveEditorLayout { + static let cardVerticalPadding: Float = 8 + static let contentGap: Float = 7 + static let toolbarHeight: Float = 32 + static let previewHeight: Float = 72 + static let keyframeHeaderHeight: Float = 20 + static let keyframeEntryHeight: Float = 24 + static let keyframeRowGap: Float = 4 + static let propertyGridLabelHeight: Float = 18 + static let propertyGridVerticalPadding: Float = 6 + static let linearValueHeight: Float = cardVerticalPadding * 2 + toolbarHeight + static let linearRowHeight: Float = propertyGridLabelHeight + + propertyGridVerticalPadding * 2 + + linearValueHeight + + static func keyframeEntryListHeight(keyframeCount: Int) -> Float { + guard keyframeCount > 0 else { return 0 } + let rows = Float(keyframeCount) * keyframeEntryHeight + let gaps = Float(max(0, keyframeCount - 1)) * keyframeRowGap + return rows + gaps + } + + static func keyframeRowsHeight(keyframeCount: Int) -> Float { + keyframeHeaderHeight + + keyframeRowGap + + keyframeEntryListHeight(keyframeCount: keyframeCount) + } + + static func valueHeight(keyframeCount: Int) -> Float { + cardVerticalPadding * 2 + + toolbarHeight + + contentGap + + previewHeight + + contentGap + + keyframeRowsHeight(keyframeCount: keyframeCount) + } + + static func rowHeight(keyframeCount: Int) -> Float { + propertyGridLabelHeight + + propertyGridVerticalPadding * 2 + + valueHeight(keyframeCount: keyframeCount) + } +} + +enum ParticleSubEmitterEditorLayout { + static let cardVerticalPadding: Float = 8 + static let propertyGridLabelHeight: Float = 18 + static let propertyGridVerticalPadding: Float = 6 + static let toolbarHeight: Float = 24 + static let emptyHeight: Float = 42 + static let ruleHeight: Float = 236 + static let ruleGap: Float = 8 + static let maxVisibleRules = 2 + + static func listHeight(ruleCount: Int) -> Float { + guard ruleCount > 0 else { return emptyHeight } + let visibleRules = min(ruleCount, maxVisibleRules) + let rulesHeight = Float(visibleRules) * ruleHeight + let gapsHeight = Float(max(0, visibleRules - 1)) * ruleGap + return rulesHeight + gapsHeight + } + + static func contentHeight(ruleCount: Int) -> Float { + let bodyHeight = ruleCount == 0 ? emptyHeight : listHeight(ruleCount: ruleCount) + return cardVerticalPadding * 2 + + toolbarHeight + + ruleGap + + bodyHeight + } + + static func valueHeight(ruleCount: Int) -> Float { + contentHeight(ruleCount: ruleCount) + } + + static func rowHeight(ruleCount: Int) -> Float { + propertyGridLabelHeight + + propertyGridVerticalPadding * 2 + + valueHeight(ruleCount: ruleCount) + } +} + +private struct ParticleCurveKeyframeRows: View { + let binding: Binding + let keyframes: [ParticleCurveKeyframe] + let selectedKeyIndex: Binding + let isEnabled: Bool + + var body: some View { + Box(direction: .column, alignItems: .stretch, spacing: ParticleCurveEditorLayout.keyframeRowGap) { + Row(alignment: .center, spacing: 6) { + Text("") + .frame(width: 30) + Text(L("Time")) + .font(.caption) + .foregroundColor(.onSurfaceMuted) + .frame(width: 74) + Text(L("Value")) + .font(.caption) + .foregroundColor(.onSurfaceMuted) + .frame(width: 74) + Spacer(minLength: 0) + } + .frame(height: ParticleCurveEditorLayout.keyframeHeaderHeight) + ParticleCurveKeyframeEntryList(binding: binding, + keyframes: keyframes, + selectedKeyIndex: selectedKeyIndex, + isEnabled: isEnabled) + } + .frame(height: ParticleCurveEditorLayout.keyframeRowsHeight(keyframeCount: keyframes.count)) + .padding(horizontal: 1, vertical: 0) + .clipped() + } +} + +private struct ParticleCurveKeyframeEntryList: _PrimitiveView { + let binding: Binding + let keyframes: [ParticleCurveKeyframe] + let selectedKeyIndex: Binding + let isEnabled: Bool + + func _makeNode() -> Node { + let node = Node() + node.isHitTestable = false + return node + } + + func _updateNode(_ node: Node) {} + + func _makeLayoutNode() -> LayoutNode? { + let layout = LayoutNode() + layout.flexDirection = .column + layout.alignItems = .stretch + layout.height = ParticleCurveEditorLayout.keyframeEntryListHeight(keyframeCount: keyframes.count) + layout.setGap(ParticleCurveEditorLayout.keyframeRowGap, gutter: .all) + return layout + } + + func _updateLayout(_ layout: LayoutNode) { + layout.flexDirection = .column + layout.alignItems = .stretch + layout.height = ParticleCurveEditorLayout.keyframeEntryListHeight(keyframeCount: keyframes.count) + layout.setGap(ParticleCurveEditorLayout.keyframeRowGap, gutter: .all) + } + + var _children: [any View] { + (0.. 2, + tooltip: L("Remove keyframe"), + action: { removeKeyframe(at: index) }) + .buttonStyle(.ghost) + .frame(width: 22, height: 22) + Spacer(minLength: 0) + } + .frame(height: ParticleCurveEditorLayout.keyframeEntryHeight) + ) + } + } + + private func keyTimeBinding(index: Int) -> Binding { + Binding( + get: { + guard case .keyframes(let keys) = binding.wrappedValue, + keys.indices.contains(index) + else { return 0 } + return keys[index].time + }, + set: { value in + updateKeyframe(at: index) { key in + key.time = min(max(value, 0), 1) + } + } + ) + } + + private func keyValueBinding(index: Int) -> Binding { + Binding( + get: { + guard case .keyframes(let keys) = binding.wrappedValue, + keys.indices.contains(index) + else { return 0 } + return keys[index].value + }, + set: { value in + updateKeyframe(at: index) { key in + key.value = value + } + } + ) + } + + private func updateKeyframe(at index: Int, + mutate: (inout ParticleCurveKeyframe) -> Void) { + guard isEnabled, + case .keyframes(var keys) = binding.wrappedValue, + keys.indices.contains(index) + else { return } + mutate(&keys[index]) + let editedKey = keys[index] + let sorted = keys.sortedByTimeStable() + binding.wrappedValue = .keyframes(sorted) + selectedKeyIndex.wrappedValue = sorted.nearestIndex(to: editedKey) + } + + private func removeKeyframe(at index: Int) { + guard isEnabled, keyframes.count > 2, keyframes.indices.contains(index) else { return } + var next = keyframes + next.remove(at: index) + binding.wrappedValue = .keyframes(next.sortedByTimeStable()) + selectedKeyIndex.wrappedValue = next.isEmpty ? nil : min(index, next.count - 1) + } +} + +private struct ParticleCurvePreview: View { + let binding: Binding + let selectedKeyIndex: Binding + let isEnabled: Bool + + var body: some View { + ParticleCurvePreviewHost(binding: binding, + selectedKeyIndex: selectedKeyIndex, + isEnabled: isEnabled) + .frame(minWidth: 120, minHeight: 44) + } +} + +private struct ParticleCurvePreviewHost: _PrimitiveView { + let binding: Binding + let selectedKeyIndex: Binding + let isEnabled: Bool + + private static let activeKeyIndex = "__particle_curve_active_key_index" + + func _makeNode() -> Node { + let node = Node() + node.isHitTestable = isEnabled + return node + } + + func _updateNode(_ node: Node) { + let snapshot = self + node.isHitTestable = isEnabled + node.cursor = isEnabled ? .pointer : .notAllowed + node.draw = { list, origin in + snapshot.render(node: node, origin: origin, list: list) + } + + guard let registry = InteractionRegistryHolder.current else { + InteractionRegistryHolder.current?.remove(node) + return + } + registry.setPointer(node) { event, phase, _ in + guard snapshot.isEnabled, event.button == .left else { return .ignored } + switch phase { + case .down: + PointerCaptureHolder.current?.acquire(node) + let graph = snapshot.graphRect(for: node) + let index = snapshot.pickOrInsertKey(windowX: event.x, windowY: event.y, graph: graph) + let nextIndex = snapshot.writeKey(at: index, + windowX: event.x, + windowY: event.y, + graph: graph) ?? index + node.attachments[Self.activeKeyIndex] = nextIndex + snapshot.selectedKeyIndex.wrappedValue = nextIndex + return .handled + case .up: + if let index = node.attachments[Self.activeKeyIndex] as? Int { + snapshot.selectedKeyIndex.wrappedValue = index + } + node.attachments[Self.activeKeyIndex] = nil + if PointerCaptureHolder.current?.target === node { + PointerCaptureHolder.current?.release() + } + return .handled + } + } + registry.setMotion(node) { event, _ in + guard snapshot.isEnabled, + PointerCaptureHolder.current?.target === node, + let index = node.attachments[Self.activeKeyIndex] as? Int + else { return .ignored } + if let nextIndex = snapshot.writeKey(at: index, + windowX: event.x, + windowY: event.y, + graph: snapshot.graphRect(for: node)) { + node.attachments[Self.activeKeyIndex] = nextIndex + snapshot.selectedKeyIndex.wrappedValue = nextIndex + } + return .handled + } + } + + func _makeLayoutNode() -> LayoutNode? { + let layout = LayoutNode() + layout.height = ParticleCurveEditorLayout.previewHeight + return layout + } + + func _updateLayout(_ layout: LayoutNode) { + layout.height = ParticleCurveEditorLayout.previewHeight + } + + private func render(node: Node, origin: CGPoint, list: DrawList) { + let width = Float(node.frame.width) + let height = Float(node.frame.height) + guard width > 2, height > 2 else { return } + + let curve = binding.wrappedValue + let colors = node.theme.colors + let x = Float(origin.x) + let y = Float(origin.y) + let rect = UIRect(x: x, y: y, width: width, height: height) + list.addRoundedRect(rect, radius: 5, color: colors.surfaceSunken) + drawBorder(rect: rect, color: colors.border, list: list) + + let inset: Float = 6 + let graph = UIRect(x: x + inset, + y: y + inset, + width: max(1, width - inset * 2), + height: max(1, height - inset * 2)) + + for fraction in [Float(0.25), Float(0.5), Float(0.75)] { + let gx = graph.minX + graph.width * fraction + list.addRect(UIRect(x: gx, y: graph.minY, width: 1, height: graph.height), + color: colors.divider) + let gy = graph.minY + graph.height * fraction + list.addRect(UIRect(x: graph.minX, y: gy, width: graph.width, height: 1), + color: colors.divider) + } + + let samples = 28 + var previous: (x: Float, y: Float)? + for sample in 0...samples { + let t = Float(sample) / Float(samples) + let value = curve.evaluate(at: t) + let px = graph.minX + t * graph.width + let py = graph.maxY - min(max(value, 0), 1) * graph.height + if let previous { + list.addLine(fromX: previous.x, fromY: previous.y, + toX: px, toY: py, + thickness: 2, + color: colors.accent) + } + previous = (px, py) + } + + if case .keyframes(let keyframes) = curve { + let active = node.attachments[Self.activeKeyIndex] as? Int ?? selectedKeyIndex.wrappedValue + for (index, key) in keyframes.enumerated() { + let px = graph.minX + key.time * graph.width + let py = graph.maxY - min(max(key.value, 0), 1) * graph.height + let radius: Float = active == index ? 3.5 : 2.5 + let marker = UIRect(x: px - radius, + y: py - radius, + width: radius * 2, + height: radius * 2) + list.addRoundedRect(marker, + radius: radius, + color: active == index ? colors.warning : colors.accentSecondary) + } + } + } + + private func graphRect(for node: Node) -> UIRect { + let inset: Float = 6 + let x = Float(node.absoluteOrigin.x) + let y = Float(node.absoluteOrigin.y) + let width = max(1, Float(node.frame.width) - inset * 2) + let height = max(1, Float(node.frame.height) - inset * 2) + return UIRect(x: x + inset, y: y + inset, width: width, height: height) + } + + private func pickOrInsertKey(windowX: Float, windowY: Float, graph: UIRect) -> Int { + guard case .keyframes(let keyframes) = binding.wrappedValue else { return 0 } + if let index = nearestKeyIndex(windowX: windowX, windowY: windowY, graph: graph, keyframes: keyframes) { + return index + } + + var next = keyframes + let key = keyframe(windowX: windowX, windowY: windowY, graph: graph) + next.append(key) + let sorted = next.sortedByTimeStable() + binding.wrappedValue = .keyframes(sorted) + let index = nearestKeyIndex(windowX: windowX, + windowY: windowY, + graph: graph, + keyframes: sorted) ?? max(0, sorted.count - 1) + selectedKeyIndex.wrappedValue = index + return index + } + + private func nearestKeyIndex(windowX: Float, + windowY: Float, + graph: UIRect, + keyframes: [ParticleCurveKeyframe]) -> Int? { + let thresholdSquared: Float = 64 + var best: (index: Int, distance: Float)? + for (index, key) in keyframes.enumerated() { + let px = graph.minX + key.time * graph.width + let py = graph.maxY - min(max(key.value, 0), 1) * graph.height + let dx = px - windowX + let dy = py - windowY + let distance = dx * dx + dy * dy + if distance <= thresholdSquared, best == nil || distance < best!.distance { + best = (index, distance) + } + } + return best?.index + } + + private func writeKey(at index: Int, windowX: Float, windowY: Float, graph: UIRect) -> Int? { + guard case .keyframes(var keyframes) = binding.wrappedValue, + keyframes.indices.contains(index) + else { return nil } + keyframes[index] = keyframe(windowX: windowX, windowY: windowY, graph: graph) + let sorted = keyframes.sortedByTimeStable() + binding.wrappedValue = .keyframes(sorted) + let nextIndex = nearestKeyIndex(windowX: windowX, + windowY: windowY, + graph: graph, + keyframes: sorted) + selectedKeyIndex.wrappedValue = nextIndex + return nextIndex + } + + private func keyframe(windowX: Float, windowY: Float, graph: UIRect) -> ParticleCurveKeyframe { + let time = min(max((windowX - graph.minX) / max(1, graph.width), 0), 1) + let value = min(max((graph.maxY - windowY) / max(1, graph.height), 0), 1) + return ParticleCurveKeyframe(time: time, value: value) + } + + private func drawBorder(rect: UIRect, color: Color, list: DrawList) { + list.addRect(UIRect(x: rect.minX, y: rect.minY, width: rect.width, height: 1), color: color) + list.addRect(UIRect(x: rect.minX, y: rect.maxY - 1, width: rect.width, height: 1), color: color) + list.addRect(UIRect(x: rect.minX, y: rect.minY, width: 1, height: rect.height), color: color) + list.addRect(UIRect(x: rect.maxX - 1, y: rect.minY, width: 1, height: rect.height), color: color) + } +} + +private extension Array where Element == ParticleCurveKeyframe { + func sortedByTimeStable() -> [ParticleCurveKeyframe] { + enumerated() + .sorted { + if $0.element.time == $1.element.time { + return $0.offset < $1.offset + } + return $0.element.time < $1.element.time + } + .map(\.element) + } + + func nearestIndex(to keyframe: ParticleCurveKeyframe) -> Int? { + guard !isEmpty else { return nil } + return indices.min { lhs, rhs in + let left = distanceSquared(self[lhs], keyframe) + let right = distanceSquared(self[rhs], keyframe) + return left < right + } + } + + private func distanceSquared(_ lhs: ParticleCurveKeyframe, _ rhs: ParticleCurveKeyframe) -> Float { + let dt = lhs.time - rhs.time + let dv = lhs.value - rhs.value + return dt * dt + dv * dv + } +} diff --git a/Editor/Sources/EditorApp/Panels/InspectorPanel.swift b/Editor/Sources/EditorApp/Panels/InspectorPanel.swift index 951dcc77..367d5a04 100644 --- a/Editor/Sources/EditorApp/Panels/InspectorPanel.swift +++ b/Editor/Sources/EditorApp/Panels/InspectorPanel.swift @@ -4,196 +4,24 @@ import CoreGraphics import EditorCore import GuavaUICompose import GuavaUIRuntime -import RenderBackend import SceneRuntime -private struct InspectorParticleRuntimeDiagnostics { - let frameStats: ParticleFrameStatsResource - let scalability: ParticleScalabilityStateResource - let renderStats: RenderFrameStats - let eventReport: ParticleSimulationEventApplyReport - let selectedEntityID: UInt64? - let selectedGPUSimulationPlan: ParticleGPUSimulationPlan? - let moduleValidationIssues: [ParticleModuleIssue] - - static let empty = InspectorParticleRuntimeDiagnostics( - frameStats: .empty, - scalability: .default, - renderStats: .init(), - eventReport: .empty, - selectedEntityID: nil, - selectedGPUSimulationPlan: nil, - moduleValidationIssues: [] - ) - - var selectedFrameStats: ParticleEmitterFrameStats? { - frameStats.emitterStats(for: selectedEntityID) - } - - var selectedEventStats: ParticleEmitterFrameStats? { - eventReport.emitterStats(for: selectedEntityID) - } - - var statsScopeLabel: String { - selectedFrameStats != nil ? L("Selected") : L("Scene") - } - - var liveParticleCount: Int { - selectedFrameStats?.liveParticleCount ?? frameStats.liveParticleCount - } - - var liveBudgetLimit: Int { - selectedFrameStats?.liveParticleBudgetLimit ?? frameStats.liveParticleBudgetLimit - } - - var liveBudgetText: String { - liveBudgetLimit > 0 ? "\(liveParticleCount)/\(liveBudgetLimit)" : "\(liveParticleCount)" - } - - var liveBudgetPressure: Float { - selectedFrameStats?.liveParticleBudgetUtilization ?? frameStats.liveParticleBudgetUtilization - } - - var runtimePressureLevel: ParticleRuntimePressureLevel { - selectedFrameStats?.runtimePressureLevel ?? frameStats.runtimePressureLevel - } - - var simulatedDeltaTime: Float { - selectedFrameStats?.simulatedDeltaTime ?? frameStats.simulatedDeltaTime - } - - var spawnedParticleCount: Int { - selectedFrameStats?.spawnedParticleCount ?? frameStats.spawnedParticleCount - } - - var requestedSpawnCount: Int { - selectedFrameStats?.requestedSpawnCount ?? frameStats.requestedSpawnCount - } - - var spawnBudgetLimit: Int { - selectedFrameStats?.spawnBudgetLimit ?? frameStats.spawnBudgetLimit - } - - var spawnBudgetConsumedCount: Int { - selectedFrameStats?.spawnBudgetConsumedCount ?? frameStats.spawnBudgetConsumedCount - } - - var spawnBudgetText: String { - spawnBudgetLimit > 0 ? "\(spawnBudgetConsumedCount)/\(spawnBudgetLimit)" : "\(spawnBudgetConsumedCount)/unlimited" - } - - var capacityLimitedSpawnCount: Int { - selectedFrameStats?.capacityLimitedSpawnCount ?? frameStats.capacityLimitedSpawnCount - } - - var spawnBudgetLimitedCount: Int { - selectedFrameStats?.spawnBudgetLimitedCount ?? frameStats.spawnBudgetLimitedCount - } - - var droppedSpawnCount: Int { - selectedFrameStats?.droppedSpawnCount ?? frameStats.droppedSpawnCount - } - - var collisionCount: Int { - selectedFrameStats?.collisionCount ?? frameStats.collisionCount - } - - var expiredParticleCount: Int { - selectedFrameStats?.expiredParticleCount ?? frameStats.expiredParticleCount - } - - var subEmitterSpawnedCount: Int { - selectedEventStats?.subEmitterSpawnedCount - ?? selectedFrameStats?.subEmitterSpawnedCount - ?? eventReport.subEmitterSpawnedCount - } - - var eventCapacityLimitedSpawnCount: Int { - selectedEventStats?.capacityLimitedSpawnCount ?? eventReport.capacityLimitedSpawnCount - } - - var eventSpawnBudgetLimitedCount: Int { - selectedEventStats?.spawnBudgetLimitedCount ?? eventReport.spawnBudgetLimitedCount - } - - var eventDroppedSpawnCount: Int { - selectedEventStats?.droppedSpawnCount ?? eventReport.droppedSpawnCount - } - - var eventRequestedSpawnCount: Int { - selectedEventStats?.requestedSpawnCount ?? eventReport.requestedSpawnCount - } - - var eventSpawnBudgetLimit: Int { - selectedEventStats?.spawnBudgetLimit ?? eventReport.spawnBudgetLimit - } - - var eventSpawnBudgetConsumedCount: Int { - selectedEventStats?.spawnBudgetConsumedCount ?? eventReport.spawnBudgetConsumedCount - } - - var eventSpawnBudgetText: String { - eventSpawnBudgetLimit > 0 - ? "\(eventSpawnBudgetConsumedCount)/\(eventSpawnBudgetLimit)" - : "\(eventSpawnBudgetConsumedCount)/unlimited" - } - - var runtimeDropCount: Int { - droppedSpawnCount - + eventDroppedSpawnCount - + eventReport.droppedReadbackEventCount - } - - var hasGPUSimulationWork: Bool { - renderStats.gpuParticleSimulationBatchCount > 0 - || renderStats.gpuParticleSimulationParticleCount > 0 - || renderStats.gpuParticleSimulationDispatchWorkgroups > 0 - } - - var hasGPUEventPressure: Bool { - eventReport.droppedReadbackEventCount > 0 - || renderStats.gpuParticleSimulationEventCapacity > 0 - || eventReport.totalReadbackEventCount > 0 - } - - func validationIssues(for moduleID: String) -> [ParticleModuleIssue] { - moduleValidationIssues.filter { $0.moduleID == moduleID } - } -} - struct InspectorPanel: View { let store: EditorStore let scene: EditorSceneAdapter - let renderStatsProvider: () -> RenderFrameStats - let particleEventReportProvider: () -> ParticleSimulationEventApplyReport - init(store: EditorStore, - scene: EditorSceneAdapter, - renderStatsProvider: @escaping () -> RenderFrameStats = { .init() }, - particleEventReportProvider: @escaping () -> ParticleSimulationEventApplyReport = { .empty }) { + init(store: EditorStore, scene: EditorSceneAdapter) { self.store = store self.scene = scene - self.renderStatsProvider = renderStatsProvider - self.particleEventReportProvider = particleEventReportProvider } var body: some View { StoreScope(store) { store in let _ = store.sceneRevision - let _ = store.frameTimingRevision let selectedEntityID = store.selectedEntityID let entity = scene.entitySummary(id: selectedEntityID) let sections = scene.inspectorSections(for: selectedEntityID) let collapsedIDs = store.inspectorCollapsedSectionIDs - let particleDiagnostics = InspectorParticleRuntimeDiagnostics( - frameStats: scene.currentParticleFrameStats(), - scalability: scene.currentParticleScalabilityState(), - renderStats: renderStatsProvider(), - eventReport: particleEventReportProvider(), - selectedEntityID: selectedEntityID, - selectedGPUSimulationPlan: scene.currentParticleGPUSimulationPlan(for: selectedEntityID), - moduleValidationIssues: scene.currentParticleModuleValidationIssues(for: selectedEntityID) - ) Box(direction: .column, alignItems: .stretch) { if let entity { @@ -204,8 +32,7 @@ struct InspectorPanel: View { PropertyGrid(propertySections(sections, collapsedIDs: collapsedIDs, - entityID: selectedEntityID, - particleDiagnostics: particleDiagnostics), + entityID: selectedEntityID), labelWidth: 108, minValueWidth: 132, rowHeight: 26, @@ -292,4282 +119,154 @@ struct InspectorPanel: View { } } - private struct InspectorParticleModuleStackValue: View { - private static let advancedModuleIDs: Set = [ - "textureSheet", - "trails", - "subEmitters", - "gpuSimulation", - ] - private static let stageBadgeWidth: Float = 78 - - let binding: Binding - let diagnostics: InspectorParticleRuntimeDiagnostics - - private struct ModuleDiagnosticChip { - let label: String - let value: String - let foreground: SemanticColorRef - let background: SemanticColorRef - - init(_ label: String, - _ value: String, - foreground: SemanticColorRef = .onSurfaceVariant, - background: SemanticColorRef = .surface) { - self.label = label - self.value = value - self.foreground = foreground - self.background = background - } - } - - private enum ModuleRuntimeTone: Equatable { - case muted - case info - case success - case warning - case error - - var foreground: SemanticColorRef { - switch self { - case .muted: return .onSurfaceMuted - case .info: return .info - case .success: return .success - case .warning: return .warning - case .error: return .error - } - } - - var background: SemanticColorRef { - switch self { - case .muted: return .surfaceSunken - case .info: return .info.opacity(0.12) - case .success: return .success.opacity(0.12) - case .warning: return .warning.opacity(0.12) - case .error: return .error.opacity(0.12) - } - } - - var accent: SemanticColorRef? { - switch self { - case .muted: return nil - case .info: return .info - case .success: return .success - case .warning: return .warning - case .error: return .error - } - } - - var isAlert: Bool { - self == .warning || self == .error - } - - var isActive: Bool { - self == .info || self == .success - } - } - - private struct ModuleRuntimeStatus { - let text: String - let tone: ModuleRuntimeTone - } - - private struct ModuleStatusCard { - let label: String - let value: String - let detail: String - let tone: ModuleRuntimeTone - } - - private struct ModuleHealthSnapshot { - let authoring: ModuleStatusCard - let runtime: ModuleStatusCard - let backend: ModuleStatusCard - let pressure: ModuleStatusCard - let issues: ModuleStatusCard - - var primaryStatus: ModuleStatusCard { - if issues.tone.isAlert { return issues } - if pressure.tone.isAlert { return pressure } - if runtime.tone.isActive { return runtime } - if backend.tone.isActive { return backend } - if pressure.tone.isActive { return pressure } - return runtime - } - } - - private var stack: ParticleModuleStack { - binding.wrappedValue - } - - private var enabledModuleCount: Int { - stack.modules.filter(\.isEnabled).count - } - - private var advancedModuleCount: Int { - stack.modules.filter { isAdvancedModule($0) }.count - } - - private var modifiedModuleCount: Int { - stack.modifiedModuleIDs.count - } - - private var coreModuleIndices: [Int] { - stack.modules.indices.filter { !isAdvancedModule(stack.modules[$0]) } - } - - private var advancedModuleIndices: [Int] { - stack.modules.indices.filter { isAdvancedModule(stack.modules[$0]) } - } - private var hasValidationIssues: Bool { - !diagnostics.moduleValidationIssues.isEmpty || !stack.validationIssues().isEmpty - } + private struct InspectorBooleanValue: View { + let binding: Binding var body: some View { - Box(direction: .column, alignItems: .stretch, spacing: 8) { - Box(direction: .column, alignItems: .stretch, spacing: 6) { - Row(alignment: .center, spacing: 8) { - Text(L("Modules")) - .lineLimit(1) - .font(.bodyStrong) - .foregroundColor(.onSurface) - .flex() - - Button(L("Reset"), - tooltip: L("Reset module order and enabled states")) { - var stack = binding.wrappedValue - stack.resetAuthoringState() - binding.wrappedValue = stack - } - .buttonStyle(GhostButtonStyle()) - } - - Box(direction: .row, alignItems: .center, wrap: .wrap, spacing: 6) { - summaryChip("\(L("Active")) \(enabledModuleCount) / \(stack.modules.count)", - foreground: .info, - background: .info.opacity(0.12)) - summaryChip(gpuSummaryText, - foreground: gpuSummaryForeground, - background: gpuSummaryBackground) - summaryChip("\(L("Advanced")) \(advancedModuleCount)") - - if modifiedModuleCount > 0 { - Button("\(L("Modified")) \(modifiedModuleCount)", - tooltip: L("Expand modified modules")) { - var stack = binding.wrappedValue - stack.expandModifiedModules(collapseOthers: true) - binding.wrappedValue = stack - } - .buttonStyle(GhostButtonStyle()) - } - - if hasValidationIssues { - Button(L("Issues"), - tooltip: L("Expand modules with validation issues")) { - var stack = binding.wrappedValue - let issueModuleIDs = diagnostics.moduleValidationIssues.map(\.moduleID) - + stack.validationIssues().map(\.moduleID) - stack.expandModules(withIDs: issueModuleIDs, collapseOthers: true) - binding.wrappedValue = stack - } - .buttonStyle(GhostButtonStyle()) - } - } - .debugName("particle-module-stack-summary") - } - - Box(direction: .column, alignItems: .stretch, spacing: 4) { - moduleGroup(title: L("Core"), - subtitle: "\(enabledCount(in: coreModuleIndices))/\(coreModuleIndices.count)", - indices: coreModuleIndices) - - if !advancedModuleIndices.isEmpty { - moduleGroup(title: L("Advanced"), - subtitle: "\(enabledCount(in: advancedModuleIndices))/\(advancedModuleIndices.count)", - indices: advancedModuleIndices) - } - } - .debugName("particle-module-stack-list") - } - .padding(horizontal: 8, vertical: 8) - .background(.surface) - .cornerRadius(6) - .border(.divider, width: 1) - .clipped() - .debugName("particle-module-stack") - } - - private var gpuSummaryText: String { - gpuSummaryStatus.text - } - - private var gpuSummaryStatus: ModuleRuntimeStatus { - guard let module = stack.modules.first(where: { $0.id == "gpuSimulation" }), - module.isEnabled, - case let .gpuSimulation(settings) = module.settings else { - return ModuleRuntimeStatus(text: L("GPU Off"), tone: .muted) - } - if diagnostics.eventReport.droppedReadbackEventCount > 0 { - return ModuleRuntimeStatus(text: L("GPU Dropping"), tone: .warning) - } - if diagnostics.hasGPUSimulationWork { - return ModuleRuntimeStatus(text: L("GPU Active"), tone: .success) - } - if let plan = diagnostics.selectedGPUSimulationPlan { - switch plan.status { - case .disabled: - return ModuleRuntimeStatus(text: L("GPU Off"), tone: .muted) - case .supported: - return ModuleRuntimeStatus(text: L("GPU Ready"), tone: .success) - case .fallbackToCPU: - return ModuleRuntimeStatus(text: L("CPU Fallback"), tone: .warning) - case .requiredButUnsupported: - return ModuleRuntimeStatus(text: L("GPU Blocked"), tone: .warning) - } - } - switch settings.simulationBackend { - case .cpu: - return ModuleRuntimeStatus(text: L("GPU Off"), tone: .muted) - case .gpuIfSupported: - return ModuleRuntimeStatus(text: L("GPU Preferred"), tone: .info) - case .gpuRequired: - return ModuleRuntimeStatus(text: L("GPU Required"), tone: .warning) - } - } - - private var gpuSummaryForeground: SemanticColorRef { - gpuSummaryStatus.tone.foreground - } - - private var gpuSummaryBackground: SemanticColorRef { - gpuSummaryStatus.tone.background - } - - private func summaryChip(_ text: String, - foreground: SemanticColorRef = .onSurfaceMuted, - background: SemanticColorRef = .surface) -> some View { - Text(text) - .lineLimit(1) - .font(.caption) - .foregroundColor(foreground) - .padding(horizontal: 7, vertical: 2) - .background(background) - .cornerRadius(3) - .border(foreground.opacity(0.10), width: 1) - } - - private func moduleGroup(title: String, subtitle: String, indices: [Int]) -> AnyView { - AnyView(Box(direction: .column, alignItems: .stretch, spacing: 4) { - if isAdvancedGroup(title: title) { - Row(alignment: .center, spacing: 10) { - Box { EmptyView() } - .frame(height: 1) - .background(.divider) - .flex() - Text(title) - .lineLimit(1) - .font(.caption) - .foregroundColor(.onSurfaceMuted) - Box { EmptyView() } - .frame(height: 1) - .background(.divider) - .flex() - } - .padding(horizontal: 14, vertical: 8) - } - - Box(direction: .column, alignItems: .stretch, spacing: 3) { - for index in indices { - moduleRow(index) - } - } - }) - } - - private func moduleRow(_ index: Int) -> AnyView { - let module = stack.modules[index] - let health = moduleHealthSnapshot(module) - let isModified = stack.moduleSettingsDifferFromDefault(module.id) - return AnyView(Box(direction: .column, alignItems: .stretch, spacing: 0) { - Row(alignment: .center, spacing: 8) { - Button(icon: .resource(module.isExpanded ? UICommonIcons.chevronDown : UICommonIcons.chevronRight), - size: 9, - tooltip: module.isExpanded ? L("Collapse module") : L("Expand module")) { - toggleModuleExpanded(index) - } - .frame(width: 22) - .buttonStyle(GhostButtonStyle()) - - Checkbox(isOn: moduleEnabledBinding(index)) - .frame(width: 22) - - Box { EmptyView() } - .frame(width: 3, height: 30) - .background(moduleAccent(module)) - .cornerRadius(2) - .opacity(module.isEnabled ? 0.85 : 0.35) - - Box(direction: .column, alignItems: .stretch, spacing: 4) { - Row(alignment: .center, spacing: 7) { - moduleStageBadge(module) - - Text(module.displayName) - .lineLimit(1) - .font(.bodyStrong) - .foregroundColor(module.isEnabled ? .onSurface : .onSurfaceMuted) - .flex() - .debugName("particle-module-name-\(module.id)") - - if module.id == "gpuSimulation" || health.primaryStatus.tone.isAlert { - moduleBackendPill(health.primaryStatus) - } else { - moduleHealthDot(health.primaryStatus.tone) - } - } - - Row(alignment: .center, spacing: 6) { - Text(moduleDetail(module.settings)) - .lineLimit(1) - .font(.caption) - .foregroundColor(module.isEnabled ? .onSurfaceVariant : .onSurfaceMuted) - .flex() - .debugName("particle-module-detail-\(module.id)") - - if isModified { - badge(L("Modified"), - foreground: .info, - background: .info.opacity(0.12)) - } - } - } - .opacity(module.isEnabled ? 1 : 0.62) - .frame(minWidth: 0) - .flex() - } - .padding(horizontal: 8, vertical: 7) - .background(moduleHeaderBackground(module)) - - if module.isExpanded { - expandedModuleEditor(index: index, module: module) - } - } - .background(moduleBackground(module)) - .cornerRadius(5) - .border(moduleBorder(module), width: 1) - .debugName("particle-module-row-\(module.id)")) - } - - private func isAdvancedGroup(title: String) -> Bool { - title == L("Advanced") - } - - private func moduleHeaderBackground(_ module: ParticleEmitterModule) -> SemanticColorRef { - if !module.isEnabled { return .surfaceSunken } - if module.isExpanded { return .surfaceRaised } - return .surface - } - - private func isAdvancedModule(_ module: ParticleEmitterModule) -> Bool { - Self.advancedModuleIDs.contains(module.id) - } - - private func enabledCount(in indices: [Int]) -> Int { - indices.filter { stack.modules.indices.contains($0) && stack.modules[$0].isEnabled }.count - } - - private func moduleEnabledBinding(_ index: Int) -> Binding { - Binding( - get: { binding.wrappedValue.modules.indices.contains(index) - ? binding.wrappedValue.modules[index].isEnabled - : false }, - set: { next in - var stack = binding.wrappedValue - guard stack.modules.indices.contains(index), - stack.modules[index].isEnabled != next else { return } - stack.modules[index].isEnabled = next - binding.wrappedValue = stack - } - ) - } - - private func badge(_ text: String, - foreground: SemanticColorRef = .onSurfaceMuted, - background: SemanticColorRef = .surfaceSunken) -> some View { - Text(text) - .lineLimit(1) - .font(.caption) - .foregroundColor(foreground) - .padding(horizontal: 5, vertical: 1) - .background(background) - .cornerRadius(3) - .border(foreground.opacity(0.10), width: 1) - } - - private func moduleStageBadge(_ module: ParticleEmitterModule) -> AnyView { - let accent = moduleStageAccent(module.stage) - return AnyView(Text(moduleStageLabel(module.stage)) - .lineLimit(1) - .font(.caption) - .foregroundColor(accent) - .padding(horizontal: 0, vertical: 0) - .frame(width: Self.stageBadgeWidth)) - } - - private func moduleStageLabel(_ stage: ParticleModuleStage) -> String { - switch stage { - case .spawn: - return L("SPAWN") - case .initialize: - return L("INITIALIZE") - case .update: - return L("UPDATE") - case .render: - return L("RENDER") - case .event: - return L("EVENT") - case .simulation: - return L("SYSTEM") - } - } - - private func moduleStageAccent(_ stage: ParticleModuleStage) -> SemanticColorRef { - switch stage { - case .spawn, .simulation: - return .accentSecondary - case .initialize, .event: - return .info - case .update: - return .success - case .render: - return .warning - } - } - - private func moduleBackendPill(_ card: ModuleStatusCard) -> AnyView { - AnyView(Text(card.value) - .lineLimit(1) - .font(.caption) - .foregroundColor(card.tone.foreground) - .padding(horizontal: 7, vertical: 2) - .background(card.tone.background) - .cornerRadius(4)) - } - - private func moduleHealthDot(_ tone: ModuleRuntimeTone) -> AnyView { - AnyView(Box { EmptyView() } - .frame(width: 7, height: 7) - .background(tone.foreground) - .cornerRadius(4)) - } - - private func moduleStatus(_ module: ParticleEmitterModule) -> ModuleRuntimeStatus? { - guard module.isEnabled else { - return ModuleRuntimeStatus(text: L("Off"), tone: .muted) - } - let validationIssues = diagnostics.validationIssues(for: module.id) - if validationIssues.contains(where: { $0.severity == .error }) { - return ModuleRuntimeStatus(text: L("Error"), tone: .error) - } - if validationIssues.contains(where: { $0.severity == .warning }) { - return ModuleRuntimeStatus(text: L("Warn"), tone: .warning) - } - - switch module.settings { - case .emission: - if let status = spawnDropStatus(total: diagnostics.droppedSpawnCount, - budget: diagnostics.spawnBudgetLimitedCount, - capacity: diagnostics.capacityLimitedSpawnCount) { - return status - } - if diagnostics.spawnedParticleCount > 0 { - return ModuleRuntimeStatus(text: L("Spawning"), tone: .success) - } - if diagnostics.liveParticleCount > 0 { - return ModuleRuntimeStatus(text: L("Live"), tone: .info) - } - return nil - case .shape, .velocity, .forces: - if diagnostics.spawnedParticleCount > 0 { - return ModuleRuntimeStatus(text: L("Active"), tone: .success) - } - if diagnostics.liveParticleCount > 0 { - return ModuleRuntimeStatus(text: L("Live"), tone: .info) - } - return nil - case .collision: - if diagnostics.collisionCount > 0 { - return ModuleRuntimeStatus(text: L("Hits"), tone: .success) - } - return nil - case .appearance: - if let status = spawnDropStatus(total: diagnostics.droppedSpawnCount, - budget: diagnostics.spawnBudgetLimitedCount, - capacity: diagnostics.capacityLimitedSpawnCount) { - return status - } - if diagnostics.scalability.pressure > 0 { - return ModuleRuntimeStatus(text: L("Scaled"), tone: .warning) - } - if diagnostics.expiredParticleCount > 0 { - return ModuleRuntimeStatus(text: L("Aging"), tone: .info) - } - return nil - case .textureSheet: - if diagnostics.renderStats.gpuParticleSortItemCount > 0 { - return ModuleRuntimeStatus(text: L("Sorting"), tone: .info) - } - return nil - case .renderer: - if diagnostics.renderStats.gpuParticleRenderInstanceCount > 0 { - return ModuleRuntimeStatus(text: L("Drawing"), tone: .success) - } - return nil - case .trails: - if diagnostics.renderStats.gpuParticleRenderInstanceCount > 0 { - return ModuleRuntimeStatus(text: L("Rendering"), tone: .success) - } - return nil - case .subEmitters: - if let status = spawnDropStatus(total: diagnostics.eventDroppedSpawnCount, - budget: diagnostics.eventSpawnBudgetLimitedCount, - capacity: diagnostics.eventCapacityLimitedSpawnCount) { - return status - } - if diagnostics.subEmitterSpawnedCount > 0 { - return ModuleRuntimeStatus(text: L("Spawned"), tone: .success) - } - if diagnostics.eventReport.eventCount > 0 { - return ModuleRuntimeStatus(text: L("Events"), tone: .info) - } - return nil - case let .gpuSimulation(settings): - if diagnostics.eventReport.droppedReadbackEventCount > 0 { - return ModuleRuntimeStatus(text: L("Dropping"), tone: .warning) - } - if diagnostics.hasGPUSimulationWork { - return ModuleRuntimeStatus(text: L("Active"), tone: .success) - } - if let plan = diagnostics.selectedGPUSimulationPlan { - switch plan.status { - case .disabled: - return ModuleRuntimeStatus(text: L("CPU"), tone: .muted) - case .supported: - return ModuleRuntimeStatus(text: L("Ready"), tone: .success) - case .fallbackToCPU: - return ModuleRuntimeStatus(text: L("Fallback"), tone: .warning) - case .requiredButUnsupported: - return ModuleRuntimeStatus(text: L("Blocked"), tone: .warning) - } - } - switch settings.simulationBackend { - case .cpu: - return ModuleRuntimeStatus(text: L("CPU"), tone: .muted) - case .gpuIfSupported: - return ModuleRuntimeStatus(text: L("Preferred"), tone: .info) - case .gpuRequired: - return ModuleRuntimeStatus(text: L("Waiting"), tone: .warning) - } - } - } - - private func spawnDropStatus(total: Int, - budget: Int, - capacity: Int) -> ModuleRuntimeStatus? { - guard total > 0 else { return nil } - if budget > 0 && capacity > 0 { - return ModuleRuntimeStatus(text: L("Mixed Drop"), tone: .warning) - } - if budget > 0 { - return ModuleRuntimeStatus(text: L("Budget Drop"), tone: .warning) - } - if capacity > 0 { - return ModuleRuntimeStatus(text: L("Capacity Drop"), tone: .warning) - } - return ModuleRuntimeStatus(text: L("Dropping"), tone: .warning) - } - - private func moduleAccent(_ module: ParticleEmitterModule) -> SemanticColorRef { - guard module.isEnabled else { return .divider } - if let accent = moduleStatus(module)?.tone.accent { - return accent - } - if module.id == "gpuSimulation", diagnostics.hasGPUSimulationWork { - return .success - } - switch module.stage { - case .spawn, .initialize: - return .accent - case .update: - return .info - case .render: - return .accentSecondary - case .event: - return .warning - case .simulation: - return .success - } - } - - private func moduleBackground(_ module: ParticleEmitterModule) -> SemanticColorRef { - let health = moduleHealthSnapshot(module) - if !module.isEnabled { return .surfaceSunken } - if health.primaryStatus.tone == .error { - return .error.opacity(0.08) - } - if health.primaryStatus.tone == .warning { - return .warning.opacity(0.08) - } - return module.isExpanded ? .surfaceRaised : .surface - } - - private func moduleBorder(_ module: ParticleEmitterModule) -> SemanticColorRef { - let health = moduleHealthSnapshot(module) - if !module.isEnabled { return .divider } - if health.primaryStatus.tone == .error { - return .error - } - if health.primaryStatus.tone == .warning { - return .warning - } - if health.primaryStatus.tone == .success { - return module.isExpanded ? .success : .divider - } - return module.isExpanded ? .borderStrong : .divider - } - - private func toggleModuleExpanded(_ index: Int) { - var stack = binding.wrappedValue - guard stack.modules.indices.contains(index) else { return } - stack.modules[index].isExpanded.toggle() - binding.wrappedValue = stack - } - - private func expandedModuleEditor(index: Int, module: ParticleEmitterModule) -> AnyView { - let issues = diagnostics.validationIssues(for: module.id) - return AnyView(Box(direction: .column, alignItems: .stretch, spacing: 7) { - moduleParameterSurface(index: index, module: module) - - if case .gpuSimulation = module.settings { - gpuBackendStatusPanel(module) - } - - if !issues.isEmpty { - moduleIssueDetails(issues) - } - - let chips = moduleDiagnosticChips(module) - if !chips.isEmpty { - Box(direction: .column, alignItems: .stretch, spacing: 4) { - Text(L("Runtime")) - .lineLimit(1) - .font(.caption) - .foregroundColor(.onSurfaceMuted) - Box(direction: .row, alignItems: .center, wrap: .wrap, spacing: 4) { - for chip in chips { - diagnosticChip(chip.label, - chip.value, - foreground: chip.foreground, - background: chip.background) - } - } - } - } - } - .padding(horizontal: 8, vertical: 8) - .background(.surface) - .border(.divider, width: 1)) - } - - private func moduleHealthSnapshot(_ module: ParticleEmitterModule, - issues: [ParticleModuleIssue]? = nil) -> ModuleHealthSnapshot { - let resolvedIssues = issues ?? diagnostics.validationIssues(for: module.id) - return ModuleHealthSnapshot(authoring: moduleAuthoringCard(module), - runtime: moduleRuntimeCard(module), - backend: moduleBackendCard(module), - pressure: modulePressureCard(module), - issues: moduleIssueCard(resolvedIssues)) - } - - private func moduleAuthoringCard(_ module: ParticleEmitterModule) -> ModuleStatusCard { - let isModified = stack.moduleSettingsDifferFromDefault(module.id) - return ModuleStatusCard(label: L("Authoring"), - value: isModified ? L("Modified") : L("Default"), - detail: module.isEnabled ? L("enabled") : L("disabled"), - tone: isModified ? .info : .muted) - } - - private func moduleRuntimeCard(_ module: ParticleEmitterModule) -> ModuleStatusCard { - let status = moduleStatus(module) ?? ModuleRuntimeStatus(text: module.isEnabled ? L("Idle") : L("Off"), - tone: .muted) - return ModuleStatusCard(label: L("Runtime"), - value: status.text, - detail: diagnostics.statsScopeLabel, - tone: status.tone) - } - - private func moduleBackendCard(_ module: ParticleEmitterModule) -> ModuleStatusCard { - let status = moduleBackendStatus(module) - let detail: String - if case .gpuSimulation = module.settings, - let plan = diagnostics.selectedGPUSimulationPlan { - detail = "\(plan.dispatchWorkgroups)x\(plan.workgroupSize)" - } else if diagnostics.hasGPUSimulationWork { - detail = L("simulation active") - } else { - detail = L("editor linked") - } - return ModuleStatusCard(label: L("Backend"), - value: status.text, - detail: detail, - tone: status.tone) - } - - private func modulePressureCard(_ module: ParticleEmitterModule) -> ModuleStatusCard { - guard module.isEnabled else { - return ModuleStatusCard(label: L("Pressure"), - value: L("Inactive"), - detail: L("module disabled"), - tone: .muted) - } - if diagnostics.runtimeDropCount > 0 { - return ModuleStatusCard(label: L("Pressure"), - value: particleRuntimePressureLevelLabel(.critical), - detail: "\(diagnostics.runtimeDropCount) \(L("drops"))", - tone: particleRuntimePressureLevelTone(.critical)) - } - if diagnostics.scalability.pressure > 0 || diagnostics.scalability.appliedScale < 1 { - return ModuleStatusCard(label: L("Pressure"), - value: particlePressureReasonLabel(diagnostics.scalability.reason), - detail: "\(L("scale")) \(fmt(diagnostics.scalability.appliedScale))", - tone: diagnostics.scalability.pressure > 0 ? .warning : .info) - } - return ModuleStatusCard(label: L("Pressure"), - value: particleRuntimePressureLevelLabel(diagnostics.runtimePressureLevel), - detail: diagnostics.liveBudgetText, - tone: particleRuntimePressureLevelTone(diagnostics.runtimePressureLevel)) - } - - private func moduleIssueCard(_ issues: [ParticleModuleIssue]) -> ModuleStatusCard { - let errors = issues.filter { $0.severity == .error }.count - let warnings = issues.filter { $0.severity == .warning }.count - if errors > 0 { - return ModuleStatusCard(label: L("Issues"), - value: "\(errors) \(L("Errors"))", - detail: warnings > 0 ? "\(warnings) \(L("warnings"))" : L("repair required"), - tone: .error) - } - if warnings > 0 { - return ModuleStatusCard(label: L("Issues"), - value: "\(warnings) \(L("Warnings"))", - detail: L("repairable"), - tone: .warning) - } - return ModuleStatusCard(label: L("Issues"), - value: L("Clean"), - detail: L("no validation issues"), - tone: .success) - } - - private func gpuBackendStatusPanel(_ module: ParticleEmitterModule) -> AnyView { - let plan = diagnostics.selectedGPUSimulationPlan - let status = plan.map(gpuPlanStatus) ?? moduleStatus(module) ?? ModuleRuntimeStatus(text: L("Unknown"), - tone: .muted) - let dispatch = plan.map { "\($0.dispatchWorkgroups) x \($0.workgroupSize)" } ?? "--" - let capacity = plan.map { "\($0.particleCapacity)" } ?? "--" - let reason = plan.map { gpuPlanUnsupportedReasonList($0.unsupportedReasons) } ?? L("No plan") - let reasonTone: ModuleRuntimeTone = plan?.unsupportedReasons.isEmpty == false ? .warning : .muted - - return AnyView(Box(direction: .column, alignItems: .stretch, spacing: 5) { - Row(alignment: .center, spacing: 6) { - Text(L("Backend Runtime")) - .lineLimit(1) - .font(.caption) - .foregroundColor(.onSurfaceMuted) - Spacer(minLength: 0) - .flex() - badge(status.text, - foreground: status.tone.foreground, - background: status.tone.background) - } - - Row(alignment: .center, spacing: 6) { - backendMetricCard(L("Capacity"), capacity, tone: .info) - backendMetricCard(L("Dispatch"), dispatch, tone: diagnostics.hasGPUSimulationWork ? .success : .muted) - backendMetricCard(L("Readback"), - "\(diagnostics.eventReport.totalReadbackEventCount)", - tone: diagnostics.hasGPUEventPressure ? .info : .muted) - } - - Row(alignment: .center, spacing: 6) { - Text(L("Reason")) - .lineLimit(1) - .font(.caption) - .foregroundColor(.onSurfaceMuted) - Text(reason) - .lineLimit(2) - .font(.caption) - .foregroundColor(reasonTone.foreground) - .flex() - } - } - .padding(horizontal: 6, vertical: 6) - .background(status.tone.background) - .cornerRadius(4) - .border(status.tone.foreground.opacity(0.35), width: 1)) - } - - private func backendMetricCard(_ label: String, - _ value: String, - tone: ModuleRuntimeTone) -> AnyView { - AnyView(Box(direction: .column, alignItems: .stretch, spacing: 1) { - Text(label) - .lineLimit(1) - .font(.caption) - .foregroundColor(.onSurfaceMuted) - Text(value) - .lineLimit(1) - .font(.caption) - .foregroundColor(tone.foreground) - } - .padding(horizontal: 6, vertical: 4) - .background(.surface) - .cornerRadius(4) - .border(tone.background, width: 1) - .flex()) - } - - private func moduleIssueDetails(_ issues: [ParticleModuleIssue]) -> AnyView { - AnyView(Box(direction: .column, alignItems: .stretch, spacing: 4) { - Text(L("Issues")) - .lineLimit(1) - .font(.caption) - .foregroundColor(.onSurfaceMuted) - for issue in issues { - moduleIssueDetailRow(issue) - } - }) - } - - private func moduleIssueDetailRow(_ issue: ParticleModuleIssue) -> AnyView { - let tone = moduleIssueTone(issue.severity) - return AnyView(Row(alignment: .center, spacing: 6) { - badge(moduleIssueSeverityLabel(issue.severity), - foreground: tone.foreground, - background: tone.background) - Text(issue.message) - .lineLimit(2) - .font(.caption) - .foregroundColor(.onSurfaceVariant) - .flex() - } - .padding(horizontal: 5, vertical: 4) - .background(tone.background) - .cornerRadius(4)) - } - - private func moduleParameterSurface(index: Int, module: ParticleEmitterModule) -> AnyView { - return AnyView(Box(direction: .column, alignItems: .stretch, spacing: 6) { - Row(alignment: .center, spacing: 6) { - Text(moduleParameterSummary(module)) - .lineLimit(1) - .font(.caption) - .foregroundColor(.onSurfaceMuted) - .flex() - - if !module.isEnabled { - badge(L("Disabled")) - } - } - - moduleEditor(index: index, module: module) - .opacity(module.isEnabled ? 1 : 0.66) - } - .padding(horizontal: 6, vertical: 6) - .background(.surfaceSunken) - .cornerRadius(4) - .border(.divider, width: 1)) - } - - private func moduleParameterSummary(_ module: ParticleEmitterModule) -> String { - switch module.settings { - case .emission: - return L("Spawn rate, bursts, prewarm, and simulation speed") - case .shape: - return L("Emitter volume and local spawn offset") - case .velocity: - return L("Initial velocity, randomness, and inheritance") - case .forces: - return L("Gravity, noise, force fields, and vector fields") - case .collision: - return L("Collision surface and response") - case .appearance: - return L("Lifetime, size, color, spin, and curves") - case .textureSheet: - return L("Atlas playback and frame window") - case .renderer: - return L("Draw mode, culling, LOD, bounds, and stretch") - case .trails: - return L("Trail cache, ribbon shape, and quality") - case .subEmitters: - return L("Event driven child particle rules") - case .gpuSimulation: - return L("Simulation backend and dispatch size") - } - } - - private func moduleDiagnosticChips(_ module: ParticleEmitterModule) -> [ModuleDiagnosticChip] { - let issueChips = moduleIssueChips(module) - guard module.isEnabled else { - return issueChips + [ - ModuleDiagnosticChip(L("State"), - L("Disabled"), - foreground: .onSurfaceMuted, - background: .surface), - ] - } - let runtimeChips: [ModuleDiagnosticChip] - switch module.settings { - case .emission: - runtimeChips = [ - ModuleDiagnosticChip(L("Scope"), diagnostics.statsScopeLabel), - runtimeMetricChip(L("Live"), diagnostics.liveParticleCount, tone: .info), - runtimeMetricChip(L("Request"), diagnostics.requestedSpawnCount, tone: .info), - runtimeMetricChip(L("Spawn"), diagnostics.spawnedParticleCount, tone: .success), - ModuleDiagnosticChip(L("Budget Use"), diagnostics.spawnBudgetText, - foreground: diagnostics.spawnBudgetLimit > 0 - && diagnostics.spawnBudgetConsumedCount >= diagnostics.spawnBudgetLimit - ? .warning - : .onSurfaceVariant, - background: diagnostics.spawnBudgetLimit > 0 - && diagnostics.spawnBudgetConsumedCount >= diagnostics.spawnBudgetLimit - ? .warning.opacity(0.12) - : .surface), - ModuleDiagnosticChip(L("Drops"), "\(diagnostics.droppedSpawnCount)", - foreground: diagnostics.droppedSpawnCount > 0 ? .warning : .onSurfaceVariant, - background: diagnostics.droppedSpawnCount > 0 ? .warning.opacity(0.12) : .surface), - ModuleDiagnosticChip(L("Budget Drops"), "\(diagnostics.spawnBudgetLimitedCount)", - foreground: diagnostics.spawnBudgetLimitedCount > 0 ? .warning : .onSurfaceVariant, - background: diagnostics.spawnBudgetLimitedCount > 0 ? .warning.opacity(0.12) : .surface), - ModuleDiagnosticChip(L("Capacity"), "\(diagnostics.capacityLimitedSpawnCount)", - foreground: diagnostics.capacityLimitedSpawnCount > 0 ? .warning : .onSurfaceVariant, - background: diagnostics.capacityLimitedSpawnCount > 0 ? .warning.opacity(0.12) : .surface), - ] - case .collision: - runtimeChips = [ - ModuleDiagnosticChip(L("Scope"), diagnostics.statsScopeLabel), - runtimeMetricChip(L("Hits"), diagnostics.collisionCount, tone: .success), - runtimeMetricChip(L("Events"), diagnostics.eventReport.collisionEventCount, tone: .info), - ModuleDiagnosticChip(L("Bounce"), moduleCollisionBounce(module.settings)), - ] - case .appearance: - runtimeChips = [ - ModuleDiagnosticChip(L("Scope"), diagnostics.statsScopeLabel), - runtimeMetricChip(L("Expired"), diagnostics.expiredParticleCount, tone: .info), - ModuleDiagnosticChip(L("Scale"), fmt(diagnostics.scalability.appliedScale)), - ModuleDiagnosticChip(L("Pressure"), fmt(diagnostics.scalability.pressure), - foreground: diagnostics.scalability.pressure > 1 ? .warning : .onSurfaceVariant, - background: diagnostics.scalability.pressure > 1 ? .warning.opacity(0.12) : .surface), - ] - case .renderer: - runtimeChips = [ - runtimeMetricChip(L("Submitted"), diagnostics.renderStats.gpuParticleRenderInstanceCount, tone: .success), - runtimeMetricChip(L("Draws"), diagnostics.renderStats.gpuParticleIndirectDrawCount, tone: .success), - runtimeMetricChip(L("Cull"), diagnostics.renderStats.gpuParticleCullCandidateCount, tone: .info), - ] - case .textureSheet: - runtimeChips = [ - runtimeMetricChip(L("Sort"), diagnostics.renderStats.gpuParticleSortItemCount, tone: .info), - runtimeMetricChip(L("Passes"), diagnostics.renderStats.gpuParticleSortPassCount, tone: .info), - ] - case .trails: - runtimeChips = [ - ModuleDiagnosticChip(L("Scope"), diagnostics.statsScopeLabel), - runtimeMetricChip(L("Live"), diagnostics.liveParticleCount, tone: .info), - runtimeMetricChip(L("Render"), diagnostics.renderStats.gpuParticleRenderInstanceCount, tone: .success), - ] - case .subEmitters: - runtimeChips = [ - ModuleDiagnosticChip(L("Events"), "\(diagnostics.eventReport.appliedEventCount)/\(diagnostics.eventReport.eventCount)"), - ModuleDiagnosticChip(L("Scope"), diagnostics.statsScopeLabel), - runtimeMetricChip(L("Request"), diagnostics.eventRequestedSpawnCount, tone: .info), - runtimeMetricChip(L("Spawned"), diagnostics.subEmitterSpawnedCount, tone: .success), - ModuleDiagnosticChip(L("Budget Use"), diagnostics.eventSpawnBudgetText, - foreground: diagnostics.eventSpawnBudgetLimit > 0 - && diagnostics.eventSpawnBudgetConsumedCount >= diagnostics.eventSpawnBudgetLimit - ? .warning - : .onSurfaceVariant, - background: diagnostics.eventSpawnBudgetLimit > 0 - && diagnostics.eventSpawnBudgetConsumedCount >= diagnostics.eventSpawnBudgetLimit - ? .warning.opacity(0.12) - : .surface), - ModuleDiagnosticChip(L("Drops"), "\(diagnostics.eventDroppedSpawnCount)", - foreground: diagnostics.eventDroppedSpawnCount > 0 ? .warning : .onSurfaceVariant, - background: diagnostics.eventDroppedSpawnCount > 0 ? .warning.opacity(0.12) : .surface), - ModuleDiagnosticChip(L("Budget Drops"), "\(diagnostics.eventSpawnBudgetLimitedCount)", - foreground: diagnostics.eventSpawnBudgetLimitedCount > 0 ? .warning : .onSurfaceVariant, - background: diagnostics.eventSpawnBudgetLimitedCount > 0 ? .warning.opacity(0.12) : .surface), - ModuleDiagnosticChip(L("Capacity"), "\(diagnostics.eventCapacityLimitedSpawnCount)", - foreground: diagnostics.eventCapacityLimitedSpawnCount > 0 ? .warning : .onSurfaceVariant, - background: diagnostics.eventCapacityLimitedSpawnCount > 0 ? .warning.opacity(0.12) : .surface), - ] - case .gpuSimulation: - runtimeChips = gpuSimulationDiagnostics() - case .shape, .velocity, .forces: - runtimeChips = [ - ModuleDiagnosticChip(L("Scope"), diagnostics.statsScopeLabel), - runtimeMetricChip(L("Live"), diagnostics.liveParticleCount, tone: .info), - runtimeMetricChip(L("Spawn"), diagnostics.spawnedParticleCount, tone: .success), - ModuleDiagnosticChip(L("Frame"), formatMs(diagnostics.frameStats.simulatedDeltaTime)), - ] - } - return issueChips + runtimeChips - } - - private func gpuSimulationDiagnostics() -> [ModuleDiagnosticChip] { - var chips: [ModuleDiagnosticChip] = [] - if let plan = diagnostics.selectedGPUSimulationPlan { - let status = gpuPlanStatus(plan) - chips.append(ModuleDiagnosticChip(L("Plan"), - status.text, - foreground: status.tone.foreground, - background: status.tone.background)) - chips.append(ModuleDiagnosticChip(L("Dispatch"), - "\(plan.dispatchWorkgroups)x\(plan.workgroupSize)")) - if !plan.unsupportedReasons.isEmpty { - chips.append(ModuleDiagnosticChip(L("Reason"), - gpuPlanUnsupportedReasonList(plan.unsupportedReasons), - foreground: .warning, - background: .warning.opacity(0.12))) - } - } - chips.append(contentsOf: [ - ModuleDiagnosticChip(L("Batches"), "\(diagnostics.renderStats.gpuParticleSimulationBatchCount)", - foreground: diagnostics.hasGPUSimulationWork ? .success : .onSurfaceVariant, - background: diagnostics.hasGPUSimulationWork ? .success.opacity(0.12) : .surface), - runtimeMetricChip(L("Particles"), diagnostics.renderStats.gpuParticleSimulationParticleCount, tone: .success), - runtimeMetricChip(L("Workgroups"), diagnostics.renderStats.gpuParticleSimulationDispatchWorkgroups, tone: .success), - ModuleDiagnosticChip(L("Readback"), "\(diagnostics.eventReport.totalReadbackEventCount)", - foreground: diagnostics.hasGPUEventPressure ? .info : .onSurfaceVariant, - background: diagnostics.hasGPUEventPressure ? .info.opacity(0.12) : .surface), - ModuleDiagnosticChip(L("Dropped"), "\(diagnostics.eventReport.droppedReadbackEventCount)", - foreground: diagnostics.eventReport.droppedReadbackEventCount > 0 ? .warning : .onSurfaceVariant, - background: diagnostics.eventReport.droppedReadbackEventCount > 0 ? .warning.opacity(0.12) : .surface), - ModuleDiagnosticChip(L("Buffer"), "\(diagnostics.renderStats.gpuParticleSimulationEventBufferBytes)B"), - ]) - return chips - } - - private func gpuPlanStatus(_ plan: ParticleGPUSimulationPlan) -> ModuleRuntimeStatus { - switch plan.status { - case .disabled: - return ModuleRuntimeStatus(text: L("CPU"), tone: .muted) - case .supported: - return ModuleRuntimeStatus(text: L("Ready"), tone: .success) - case .fallbackToCPU: - return ModuleRuntimeStatus(text: L("Fallback"), tone: .warning) - case .requiredButUnsupported: - return ModuleRuntimeStatus(text: L("Blocked"), tone: .warning) - } - } - - private func moduleBackendStatus(_ module: ParticleEmitterModule) -> ModuleRuntimeStatus { - guard module.isEnabled else { - return ModuleRuntimeStatus(text: L("Disabled"), tone: .muted) - } - - if case let .gpuSimulation(settings) = module.settings { - if diagnostics.eventReport.droppedReadbackEventCount > 0 { - return ModuleRuntimeStatus(text: L("GPU Readback Drop"), tone: .warning) - } - if diagnostics.hasGPUSimulationWork { - return ModuleRuntimeStatus(text: L("GPU Active"), tone: .success) - } - if let plan = diagnostics.selectedGPUSimulationPlan { - return gpuPlanStatus(plan) - } - switch settings.simulationBackend { - case .cpu: - return ModuleRuntimeStatus(text: L("CPU"), tone: .muted) - case .gpuIfSupported: - return ModuleRuntimeStatus(text: L("GPU Preferred"), tone: .info) - case .gpuRequired: - return ModuleRuntimeStatus(text: L("GPU Required"), tone: .warning) - } - } - - if let plan = diagnostics.selectedGPUSimulationPlan { - switch plan.status { - case .supported where diagnostics.hasGPUSimulationWork: - return ModuleRuntimeStatus(text: L("GPU Sim"), tone: .success) - case .supported: - return ModuleRuntimeStatus(text: L("GPU Ready"), tone: .info) - case .fallbackToCPU: - return ModuleRuntimeStatus(text: L("CPU Fallback"), tone: .warning) - case .requiredButUnsupported: - return ModuleRuntimeStatus(text: L("GPU Blocked"), tone: .warning) - case .disabled: - break - } - } - - switch module.settings { - case .renderer, .textureSheet, .trails: - return diagnostics.renderStats.gpuParticleRenderInstanceCount > 0 - ? ModuleRuntimeStatus(text: L("Render GPU"), tone: .success) - : ModuleRuntimeStatus(text: L("Render"), tone: .muted) - case .subEmitters: - return diagnostics.eventReport.totalReadbackEventCount > 0 - ? ModuleRuntimeStatus(text: L("GPU Events"), tone: .info) - : ModuleRuntimeStatus(text: L("CPU Events"), tone: .muted) - default: - return ModuleRuntimeStatus(text: L("CPU Sim"), tone: .muted) - } - } - - private func gpuPlanUnsupportedReasonList(_ reasons: [ParticleGPUSimulationUnsupportedReason]) -> String { - guard !reasons.isEmpty else { return L("None") } - return reasons.map(gpuPlanUnsupportedReasonLabel).joined(separator: ", ") - } - - private func gpuPlanUnsupportedReasonLabel(_ reason: ParticleGPUSimulationUnsupportedReason) -> String { - switch reason { - case .backendCPU: - return L("CPU backend") - case .noParticleCapacity: - return L("no capacity") - case .eventSubEmitters: - return L("sub-emitters") - case .distanceEmission: - return L("distance emission") - case .noise: - return L("noise") - case .forceFields: - return L("force fields") - case .collisions: - return L("collisions") - case .angularVelocity: - return L("angular velocity") - } - } - - private func particlePressureReasonLabel(_ reason: ParticleScalabilityPressureReason) -> String { - switch reason { - case .none: - return L("Recovering") - case .liveBudget: - return L("Live Budget") - case .spawnBudget: - return L("Spawn Budget") - case .capacityLimited: - return L("Capacity") - } - } - - private func particleRuntimePressureLevelLabel(_ level: ParticleRuntimePressureLevel) -> String { - switch level { - case .idle: - return L("Idle") - case .nominal: - return L("Healthy") - case .warning: - return L("Near Limit") - case .critical: - return L("Critical") - } - } - - private func particleRuntimePressureLevelTone(_ level: ParticleRuntimePressureLevel) -> ModuleRuntimeTone { - switch level { - case .idle: - return .muted - case .nominal: - return .success - case .warning: - return .warning - case .critical: - return .error - } - } - - private func diagnosticChip(_ label: String, - _ value: String, - foreground: SemanticColorRef, - background: SemanticColorRef) -> AnyView { - AnyView(Row(alignment: .center, spacing: 4) { - Text(label) - .lineLimit(1) - .font(.caption) - .foregroundColor(.onSurfaceMuted) - Text(value) - .lineLimit(1) - .font(.caption) - .foregroundColor(foreground) - } - .padding(horizontal: 5, vertical: 2) - .background(background) - .cornerRadius(3)) - } - - private func runtimeMetricChip(_ label: String, - _ value: Int, - tone: ModuleRuntimeTone = .info) -> ModuleDiagnosticChip { - ModuleDiagnosticChip(label, - "\(value)", - foreground: value > 0 ? tone.foreground : .onSurfaceVariant, - background: value > 0 ? tone.background : .surface) - } - - private func moduleIssueChips(_ module: ParticleEmitterModule) -> [ModuleDiagnosticChip] { - diagnostics.validationIssues(for: module.id).map { issue in - let tone = moduleIssueTone(issue.severity) - return ModuleDiagnosticChip(moduleIssueSeverityLabel(issue.severity), - moduleIssueTitle(issue), - foreground: tone.foreground, - background: tone.background) - } - } - - private func moduleIssueTone(_ severity: ParticleModuleIssueSeverity) -> ModuleRuntimeTone { - switch severity { - case .info: - return .info - case .warning: - return .warning - case .error: - return .error - } - } - - private func moduleIssueSeverityLabel(_ severity: ParticleModuleIssueSeverity) -> String { - switch severity { - case .info: - return L("Info") - case .warning: - return L("Warn") - case .error: - return L("Error") - } - } - - private func moduleIssueTitle(_ issue: ParticleModuleIssue) -> String { - switch issue.code { - case "noParticleCapacity": - return L("No Capacity") - case "noSpawnSource": - return L("No Spawn") - case "invalidLifetime", "subEmitterInvalidLifetime": - return L("Lifetime") - case "invalidTextureSheet": - return L("Sheet Size") - case "frameCountExceedsCells": - return L("Frame Count") - case "gpuFallbackToCPU": - return L("CPU Fallback") - case "gpuRequiredButUnsupported": - return L("GPU Blocked") - case "invalidGPUWorkgroup": - return L("Workgroup") - case "gpuWorkgroupClamped": - return L("Clamped") - case "negativeVelocityStretch", "invalidVelocityStretch": - return L("Stretch") - case "invalidLODRange", "negativeLODDistance", "lodScaleOutOfRange": - return L("LOD") - case "negativeMaxRenderDistance", "negativeFadeRange", "fadeRangeExceedsDistance": - return L("Distance") - case "negativeRenderBoundsRadius", "invalidRenderBounds": - return L("Bounds") - case "negativeTrailSettings", "trailWithoutSamples": - return L("Trails") - case "invalidRibbonSmoothing", "negativeRibbonWidth", "negativeRibbonSegment", "negativeRibbonTiling": - return L("Ribbon") - case "tailAlphaOutOfRange", "trailEndAlphaOutOfRange": - return L("Alpha") - case "negativeTrailEndSize": - return L("Trail Size") - default: - return issue.code - } - } - - private func moduleCollisionBounce(_ settings: ParticleEmitterModuleSettings) -> String { - guard case let .collision(module) = settings else { return "--" } - return fmt(module.collisionRestitution) - } - - private func moduleEditor(index: Int, module: ParticleEmitterModule) -> AnyView { - let isEnabled = module.isEnabled - switch module.settings { - case .emission: - return AnyView(Box(direction: .column, alignItems: .stretch, spacing: 8) { - moduleEditorRows([ - [ - moduleNumber("Rate", - moduleFloatBinding(index, fallback: 0, get: { - if case let .emission(module) = $0 { return module.emissionRate } - return nil - }, set: { - if case var .emission(module) = $0 { - module.emissionRate = $1 - $0 = .emission(module) - } - }), - min: 0, - max: 1000, - step: 1, - enabled: isEnabled), - moduleNumber("Max", - moduleIntBinding(index, fallback: 0, get: { - if case let .emission(module) = $0 { return module.maxParticles } - return nil - }, set: { - if case var .emission(module) = $0 { - module.maxParticles = $1 - $0 = .emission(module) - } - }), - min: 0, - max: 100_000, - step: 16, - enabled: isEnabled), - moduleNumber("Speed", - moduleFloatBinding(index, fallback: 1, get: { - if case let .emission(module) = $0 { return module.simulationSpeed } - return nil - }, set: { - if case var .emission(module) = $0 { - module.simulationSpeed = $1 - $0 = .emission(module) - } - }), - min: 0, - max: 16, - step: 0.05, - enabled: isEnabled), - ], - [ - moduleNumber("Duration", - moduleFloatBinding(index, fallback: 0, get: { - if case let .emission(module) = $0 { return module.duration } - return nil - }, set: { - if case var .emission(module) = $0 { - module.duration = $1 - $0 = .emission(module) - } - }), - min: 0, - max: 600, - step: 0.1, - enabled: isEnabled), - moduleNumber("Dist Rate", - moduleFloatBinding(index, fallback: 0, get: { - if case let .emission(module) = $0 { return module.distanceEmissionRate } - return nil - }, set: { - if case var .emission(module) = $0 { - module.distanceEmissionRate = $1 - $0 = .emission(module) - } - }), - min: 0, - max: 1000, - step: 1, - enabled: isEnabled), - moduleNumber("Burst", - moduleIntBinding(index, fallback: 0, get: { - if case let .emission(module) = $0 { return module.burstCount } - return nil - }, set: { - if case var .emission(module) = $0 { - module.burstCount = $1 - $0 = .emission(module) - } - }), - min: 0, - max: 100_000, - step: 1, - enabled: isEnabled), - ], - [ - moduleNumber("Burst Int", - moduleFloatBinding(index, fallback: 0, get: { - if case let .emission(module) = $0 { return module.burstInterval } - return nil - }, set: { - if case var .emission(module) = $0 { - module.burstInterval = $1 - $0 = .emission(module) - } - }), - min: 0, - max: 600, - step: 0.05, - enabled: isEnabled), - moduleNumber("Prewarm", - moduleFloatBinding(index, fallback: 0, get: { - if case let .emission(module) = $0 { return module.prewarmTime } - return nil - }, set: { - if case var .emission(module) = $0 { - module.prewarmTime = $1 - $0 = .emission(module) - } - }), - min: 0, - max: 600, - step: 0.1, - enabled: isEnabled), - moduleNumber("Pre Step", - moduleFloatBinding(index, fallback: 1.0 / 30.0, get: { - if case let .emission(module) = $0 { return module.prewarmStep } - return nil - }, set: { - if case var .emission(module) = $0 { - module.prewarmStep = $1 - $0 = .emission(module) - } - }), - min: 1.0 / 240.0, - max: 1, - step: 0.01, - enabled: isEnabled), - ], - [ - moduleNumber("Spawn Cap", - moduleIntBinding(index, fallback: 0, get: { - if case let .emission(module) = $0 { return module.maxSpawnedParticlesPerFrame } - return nil - }, set: { - if case var .emission(module) = $0 { - module.maxSpawnedParticlesPerFrame = $1 - $0 = .emission(module) - } - }), - min: 0, - max: 100_000, - step: 16, - enabled: isEnabled), - moduleNumber("Max Rendered", - moduleIntBinding(index, fallback: 0, get: { - if case let .emission(module) = $0 { return module.maxRenderedParticles } - return nil - }, set: { - if case var .emission(module) = $0 { - module.maxRenderedParticles = $1 - $0 = .emission(module) - } - }), - min: 0, - max: 100_000, - step: 16, - enabled: isEnabled), - moduleText("Seed", - moduleUInt64StringBinding(index, fallback: 0x9E3779B9, get: { - if case let .emission(module) = $0 { return module.seed } - return nil - }, set: { - if case var .emission(module) = $0 { - module.seed = $1 - $0 = .emission(module) - } - }), - enabled: isEnabled), - ], - ]) - moduleCurveEditor("Rate Curve", - moduleValueBinding(index, fallback: .constant(1), get: { - if case let .emission(module) = $0 { return module.emissionRateCurve } - return nil - }, set: { - if case var .emission(module) = $0 { - module.emissionRateCurve = $1 - $0 = .emission(module) - } - }), - enabled: isEnabled) - moduleCurveEditor("Distance Curve", - moduleValueBinding(index, fallback: .constant(1), get: { - if case let .emission(module) = $0 { return module.distanceEmissionRateCurve } - return nil - }, set: { - if case var .emission(module) = $0 { - module.distanceEmissionRateCurve = $1 - $0 = .emission(module) - } - }), - enabled: isEnabled) - }) - case .shape: - return shapeModuleEditor(index: index, module: module, isEnabled: isEnabled) - case .velocity: - return velocityModuleEditor(index: index, module: module, isEnabled: isEnabled) - case .forces: - return forcesModuleEditor(index: index, module: module, isEnabled: isEnabled) - case .collision: - return collisionModuleEditor(index: index, module: module, isEnabled: isEnabled) - case .appearance: - return AnyView(Box(direction: .column, alignItems: .stretch, spacing: 8) { - moduleEditorRows([ - [ - moduleEnum("Blend", - moduleValueBinding(index, fallback: .alpha, get: { - if case let .appearance(module) = $0 { return module.blendMode } - return nil - }, set: { - if case var .appearance(module) = $0 { - module.blendMode = $1 - $0 = .appearance(module) - } - }), - width: 132, - enabled: isEnabled, - label: particleBlendModeLabel), - moduleNumber("Life", - moduleFloatBinding(index, fallback: 0, get: { - if case let .appearance(module) = $0 { return module.lifetime } - return nil - }, set: { - if case var .appearance(module) = $0 { - module.lifetime = $1 - $0 = .appearance(module) - } - }), - min: 0, - max: 60, - step: 0.1, - enabled: isEnabled), - ], - [ - moduleNumber("Start", - moduleFloatBinding(index, fallback: 0, get: { - if case let .appearance(module) = $0 { return module.startSize } - return nil - }, set: { - if case var .appearance(module) = $0 { - module.startSize = $1 - $0 = .appearance(module) - } - }), - min: 0, - max: 100, - step: 0.1, - enabled: isEnabled), - moduleNumber("End", - moduleFloatBinding(index, fallback: 0, get: { - if case let .appearance(module) = $0 { return module.endSize } - return nil - }, set: { - if case var .appearance(module) = $0 { - module.endSize = $1 - $0 = .appearance(module) - } - }), - min: 0, - max: 100, - step: 0.1, - enabled: isEnabled), - ], - [ - moduleNumber("Life Rand", - moduleFloatBinding(index, fallback: 0, get: { - if case let .appearance(module) = $0 { return module.lifetimeRandomness } - return nil - }, set: { - if case var .appearance(module) = $0 { - module.lifetimeRandomness = $1 - $0 = .appearance(module) - } - }), - min: 0, - max: 60, - step: 0.05, - enabled: isEnabled), - moduleNumber("Size Rand", - moduleFloatBinding(index, fallback: 0, get: { - if case let .appearance(module) = $0 { return module.sizeRandomness } - return nil - }, set: { - if case var .appearance(module) = $0 { - module.sizeRandomness = $1 - $0 = .appearance(module) - } - }), - min: 0, - max: 100, - step: 0.05, - enabled: isEnabled), - ], - [ - moduleNumber("Rotation", - moduleFloatBinding(index, fallback: 0, get: { - if case let .appearance(module) = $0 { return module.startRotation } - return nil - }, set: { - if case var .appearance(module) = $0 { - module.startRotation = $1 - $0 = .appearance(module) - } - }), - min: -360, - max: 360, - step: 1, - enabled: isEnabled), - moduleNumber("Rot Rand", - moduleFloatBinding(index, fallback: 0, get: { - if case let .appearance(module) = $0 { return module.rotationRandomness } - return nil - }, set: { - if case var .appearance(module) = $0 { - module.rotationRandomness = $1 - $0 = .appearance(module) - } - }), - min: 0, - max: 360, - step: 1, - enabled: isEnabled), - ], - [ - moduleNumber("Spin", - moduleFloatBinding(index, fallback: 0, get: { - if case let .appearance(module) = $0 { return module.angularVelocity } - return nil - }, set: { - if case var .appearance(module) = $0 { - module.angularVelocity = $1 - $0 = .appearance(module) - } - }), - min: -3600, - max: 3600, - step: 1, - enabled: isEnabled), - moduleNumber("Spin Rand", - moduleFloatBinding(index, fallback: 0, get: { - if case let .appearance(module) = $0 { return module.angularVelocityRandomness } - return nil - }, set: { - if case var .appearance(module) = $0 { - module.angularVelocityRandomness = $1 - $0 = .appearance(module) - } - }), - min: 0, - max: 3600, - step: 1, - enabled: isEnabled), - ], - ]) - moduleColorRow([ - moduleColorEditor("Start Color", - moduleColorBinding(index, fallback: SIMD4(1, 1, 1, 1), get: { - if case let .appearance(module) = $0 { return module.startColor } - return nil - }, set: { - if case var .appearance(module) = $0 { - module.startColor = $1 - $0 = .appearance(module) - } - }), - enabled: isEnabled), - moduleColorEditor("End Color", - moduleColorBinding(index, fallback: SIMD4(1, 1, 1, 0), get: { - if case let .appearance(module) = $0 { return module.endColor } - return nil - }, set: { - if case var .appearance(module) = $0 { - module.endColor = $1 - $0 = .appearance(module) - } - }), - enabled: isEnabled), - ]) - moduleCurveEditor("Size Curve", - moduleValueBinding(index, fallback: .linear, get: { - if case let .appearance(module) = $0 { return module.sizeCurve } - return nil - }, set: { - if case var .appearance(module) = $0 { - module.sizeCurve = $1 - $0 = .appearance(module) - } - }), - enabled: isEnabled) - moduleCurveEditor("Color Curve", - moduleValueBinding(index, fallback: .linear, get: { - if case let .appearance(module) = $0 { return module.colorCurve } - return nil - }, set: { - if case var .appearance(module) = $0 { - module.colorCurve = $1 - $0 = .appearance(module) - } - }), - enabled: isEnabled) - }) - case .textureSheet: - return textureSheetModuleEditor(index: index, module: module, isEnabled: isEnabled) - case .renderer: - let status = moduleBackendStatus(module) - return AnyView(Box(direction: .column, alignItems: .stretch, spacing: 8) { - moduleEditorSection("Draw", - detail: L("Render mode, sort, and alignment"), - tone: status.tone, - content: moduleEditorRows([ - [ - moduleEnum("Mode", - moduleValueBinding(index, fallback: .billboard, get: { - if case let .renderer(module) = $0 { return module.renderMode } - return nil - }, set: { - if case var .renderer(module) = $0 { - module.renderMode = $1 - $0 = .renderer(module) - } - }), - width: 132, - enabled: isEnabled, - label: particleRenderModeLabel), - moduleEnum("Sort", - moduleValueBinding(index, fallback: .distanceDescending, get: { - if case let .renderer(module) = $0 { return module.sortMode } - return nil - }, set: { - if case var .renderer(module) = $0 { - module.sortMode = $1 - $0 = .renderer(module) - } - }), - width: 150, - enabled: isEnabled, - label: particleSortModeLabel), - moduleNumber("Priority", - moduleIntBinding(index, fallback: 0, get: { - if case let .renderer(module) = $0 { return module.renderSortPriority } - return nil - }, set: { - if case var .renderer(module) = $0 { - module.renderSortPriority = $1 - $0 = .renderer(module) - } - }), - min: -10_000, - max: 10_000, - step: 1, - enabled: isEnabled), - ], - [ - moduleEnum("Align", - moduleValueBinding(index, fallback: .billboard, get: { - if case let .renderer(module) = $0 { return module.renderAlignment } - return nil - }, set: { - if case var .renderer(module) = $0 { - module.renderAlignment = $1 - $0 = .renderer(module) - } - }), - width: 132, - enabled: isEnabled, - label: particleRenderAlignmentLabel), - moduleEnum("Bounds", - moduleValueBinding(index, fallback: .automatic, get: { - if case let .renderer(module) = $0 { return module.renderBoundsMode } - return nil - }, set: { - if case var .renderer(module) = $0 { - module.renderBoundsMode = $1 - $0 = .renderer(module) - } - }), - width: 132, - enabled: isEnabled, - label: particleRenderBoundsModeLabel), - ], - ])) - moduleEditorSection("Culling", - detail: L("Distance fade, LOD, and render bounds"), - tone: status.tone, - content: moduleEditorRows([ - [ - moduleNumber("Max Dist", - moduleFloatBinding(index, fallback: 0, get: { - if case let .renderer(module) = $0 { return module.maxRenderDistance } - return nil - }, set: { - if case var .renderer(module) = $0 { - module.maxRenderDistance = $1 - $0 = .renderer(module) - } - }), - min: 0, - max: 100_000, - step: 1, - enabled: isEnabled), - moduleNumber("Fade", - moduleFloatBinding(index, fallback: 0, get: { - if case let .renderer(module) = $0 { return module.renderDistanceFadeRange } - return nil - }, set: { - if case var .renderer(module) = $0 { - module.renderDistanceFadeRange = $1 - $0 = .renderer(module) - } - }), - min: 0, - max: 100_000, - step: 1, - enabled: isEnabled), - moduleNumber("LOD Min", - moduleFloatBinding(index, fallback: 1, get: { - if case let .renderer(module) = $0 { return module.renderLODMinParticleScale } - return nil - }, set: { - if case var .renderer(module) = $0 { - module.renderLODMinParticleScale = $1 - $0 = .renderer(module) - } - }), - min: 0, - max: 1, - step: 0.05, - enabled: isEnabled), - ], - [ - moduleNumber("LOD Start", - moduleFloatBinding(index, fallback: 0, get: { - if case let .renderer(module) = $0 { return module.renderLODStartDistance } - return nil - }, set: { - if case var .renderer(module) = $0 { - module.renderLODStartDistance = $1 - $0 = .renderer(module) - } - }), - min: 0, - max: 100_000, - step: 1, - enabled: isEnabled), - moduleNumber("LOD End", - moduleFloatBinding(index, fallback: 0, get: { - if case let .renderer(module) = $0 { return module.renderLODEndDistance } - return nil - }, set: { - if case var .renderer(module) = $0 { - module.renderLODEndDistance = $1 - $0 = .renderer(module) - } - }), - min: 0, - max: 100_000, - step: 1, - enabled: isEnabled), - moduleNumber("Bounds R", - moduleFloatBinding(index, fallback: 0, get: { - if case let .renderer(module) = $0 { return module.renderBoundsRadius } - return nil - }, set: { - if case var .renderer(module) = $0 { - module.renderBoundsRadius = $1 - $0 = .renderer(module) - } - }), - min: 0, - max: 100_000, - step: 1, - enabled: isEnabled), - ], - ])) - moduleEditorSection("Velocity Stretch", - detail: L("Billboard elongation driven by particle velocity"), - tone: status.tone, - content: moduleEditorRows([ - [ - moduleNumber("Stretch", - moduleFloatBinding(index, fallback: 0, get: { - if case let .renderer(module) = $0 { return module.velocityStretchScale } - return nil - }, set: { - if case var .renderer(module) = $0 { - module.velocityStretchScale = $1 - $0 = .renderer(module) - } - }), - min: 0, - max: 100, - step: 0.05, - enabled: isEnabled), - moduleNumber("Stretch Max", - moduleFloatBinding(index, fallback: 8, get: { - if case let .renderer(module) = $0 { return module.velocityStretchMax } - return nil - }, set: { - if case var .renderer(module) = $0 { - module.velocityStretchMax = $1 - $0 = .renderer(module) - } - }), - min: 1, - max: 1000, - step: 0.1, - enabled: isEnabled), - ], - ])) - }) - case .trails: - let status = moduleBackendStatus(module) - return AnyView(Box(direction: .column, alignItems: .stretch, spacing: 8) { - moduleEditorSection("Trail Cache", - detail: L("History length and sampling budget"), - tone: status.tone, - content: moduleEditorRows([ - [ - moduleNumber("Length", - moduleFloatBinding(index, fallback: 0, get: { - if case let .trails(module) = $0 { return module.trailLength } - return nil - }, set: { - if case var .trails(module) = $0 { - module.trailLength = $1 - $0 = .trails(module) - } - }), - min: 0, - max: 60, - step: 0.05, - enabled: isEnabled), - moduleNumber("Segments", - moduleIntBinding(index, fallback: 0, get: { - if case let .trails(module) = $0 { return module.trailSegments } - return nil - }, set: { - if case var .trails(module) = $0 { - module.trailSegments = $1 - $0 = .trails(module) - } - }), - min: 0, - max: 128, - step: 1, - enabled: isEnabled), - moduleNumber("End Alpha", - moduleFloatBinding(index, fallback: 0, get: { - if case let .trails(module) = $0 { return module.trailEndAlphaScale } - return nil - }, set: { - if case var .trails(module) = $0 { - module.trailEndAlphaScale = $1 - $0 = .trails(module) - } - }), - min: 0, - max: 1, - step: 0.05, - enabled: isEnabled), - ], - ])) - moduleEditorSection("Ribbon Shape", - detail: L("Width, taper, and alpha falloff"), - tone: status.tone, - content: moduleEditorRows([ - [ - moduleNumber("Width", - moduleFloatBinding(index, fallback: 1, get: { - if case let .trails(module) = $0 { return module.ribbonWidthScale } - return nil - }, set: { - if case var .trails(module) = $0 { - module.ribbonWidthScale = $1 - $0 = .trails(module) - } - }), - min: 0, - max: 100, - step: 0.05, - enabled: isEnabled), - moduleNumber("Tail Width", - moduleFloatBinding(index, fallback: 1, get: { - if case let .trails(module) = $0 { return module.ribbonTailWidthScale } - return nil - }, set: { - if case var .trails(module) = $0 { - module.ribbonTailWidthScale = $1 - $0 = .trails(module) - } - }), - min: 0, - max: 100, - step: 0.05, - enabled: isEnabled), - moduleNumber("Tiling", - moduleFloatBinding(index, fallback: 1, get: { - if case let .trails(module) = $0 { return module.ribbonTextureTiling } - return nil - }, set: { - if case var .trails(module) = $0 { - module.ribbonTextureTiling = $1 - $0 = .trails(module) - } - }), - min: 0, - max: 100, - step: 0.05, - enabled: isEnabled), - ], - [ - moduleNumber("End Size", - moduleFloatBinding(index, fallback: 0.5, get: { - if case let .trails(module) = $0 { return module.trailEndSizeScale } - return nil - }, set: { - if case var .trails(module) = $0 { - module.trailEndSizeScale = $1 - $0 = .trails(module) - } - }), - min: 0, - max: 100, - step: 0.05, - enabled: isEnabled), - moduleNumber("Tail Alpha", - moduleFloatBinding(index, fallback: 1, get: { - if case let .trails(module) = $0 { return module.ribbonTailAlphaScale } - return nil - }, set: { - if case var .trails(module) = $0 { - module.ribbonTailAlphaScale = $1 - $0 = .trails(module) - } - }), - min: 0, - max: 1, - step: 0.05, - enabled: isEnabled), - moduleNumber("Offset", - moduleFloatBinding(index, fallback: 0, get: { - if case let .trails(module) = $0 { return module.ribbonTextureOffset } - return nil - }, set: { - if case var .trails(module) = $0 { - module.ribbonTextureOffset = $1 - $0 = .trails(module) - } - }), - min: -100, - max: 100, - step: 0.05, - enabled: isEnabled), - ], - ])) - moduleEditorSection("Ribbon Quality", - detail: L("Segment clamp, joins, and smoothing"), - tone: status.tone, - content: moduleEditorRows([ - [ - moduleNumber("Max Segment", - moduleFloatBinding(index, fallback: 0, get: { - if case let .trails(module) = $0 { return module.ribbonMaxSegmentLength } - return nil - }, set: { - if case var .trails(module) = $0 { - module.ribbonMaxSegmentLength = $1 - $0 = .trails(module) - } - }), - min: 0, - max: 1000, - step: 0.1, - enabled: isEnabled), - moduleNumber("Join", - moduleFloatBinding(index, fallback: 0, get: { - if case let .trails(module) = $0 { return module.ribbonJoinOverlapScale } - return nil - }, set: { - if case var .trails(module) = $0 { - module.ribbonJoinOverlapScale = $1 - $0 = .trails(module) - } - }), - min: 0, - max: 10, - step: 0.05, - enabled: isEnabled), - moduleNumber("Smooth", - moduleIntBinding(index, fallback: 1, get: { - if case let .trails(module) = $0 { return module.ribbonSmoothingSegments } - return nil - }, set: { - if case var .trails(module) = $0 { - module.ribbonSmoothingSegments = $1 - $0 = .trails(module) - } - }), - min: 1, - max: 16, - step: 1, - enabled: isEnabled), - ], - ])) - }) - case .subEmitters: - return subEmittersModuleEditor(index: index, module: module, isEnabled: isEnabled) - case .gpuSimulation: - let status = moduleBackendStatus(module) - return AnyView(Box(direction: .column, alignItems: .stretch, spacing: 8) { - moduleEditorSection("Backend", - detail: L("Simulation space and GPU/CPU preference"), - tone: status.tone, - content: moduleEditorRows([ - [ - moduleEnum("Space", - moduleValueBinding(index, fallback: .local, get: { - if case let .gpuSimulation(module) = $0 { return module.simulationSpace } - return nil - }, set: { - if case var .gpuSimulation(module) = $0 { - module.simulationSpace = $1 - $0 = .gpuSimulation(module) - } - }), - width: 132, - enabled: isEnabled, - label: particleSimulationSpaceLabel), - moduleEnum("Backend", - moduleValueBinding(index, fallback: .cpu, get: { - if case let .gpuSimulation(module) = $0 { return module.simulationBackend } - return nil - }, set: { - if case var .gpuSimulation(module) = $0 { - module.simulationBackend = $1 - $0 = .gpuSimulation(module) - } - }), - width: 150, - enabled: isEnabled, - label: particleSimulationBackendLabel), - ], - ])) - moduleEditorSection("Dispatch", - detail: L("Thread group size used by the GPU simulator"), - tone: status.tone, - content: moduleEditorRows([ - [ - moduleNumber("Group", - moduleIntBinding(index, fallback: 64, get: { - if case let .gpuSimulation(module) = $0 { return module.workgroupSize } - return nil - }, set: { - if case var .gpuSimulation(module) = $0 { - module.workgroupSize = $1 - $0 = .gpuSimulation(module) - } - }), - min: 1, - max: Float(ParticleGPUSimulationPlan.maximumWorkgroupSize), - step: 1, - enabled: isEnabled), - ], - ])) - }) - } - } - - private func shapeModuleEditor(index: Int, - module: ParticleEmitterModule, - isEnabled: Bool) -> AnyView { - let status = moduleBackendStatus(module) - return AnyView(Box(direction: .column, alignItems: .stretch, spacing: 8) { - moduleEditorSection("Emitter Volume", - detail: L("Spawn primitive and sphere radius"), - tone: status.tone, - content: moduleEditorRows([ - [ - moduleEnum("Shape", - moduleValueBinding(index, fallback: .sphere, get: { - if case let .shape(module) = $0 { return module.emissionShape } - return nil - }, set: { - if case var .shape(module) = $0 { - module.emissionShape = $1 - $0 = .shape(module) - } - }), - width: 132, - enabled: isEnabled, - label: particleEmissionShapeLabel), - moduleNumber("Radius", - moduleFloatBinding(index, fallback: 0, get: { - if case let .shape(module) = $0 { return module.spawnRadius } - return nil - }, set: { - if case var .shape(module) = $0 { - module.spawnRadius = $1 - $0 = .shape(module) - } - }), - min: 0, - max: 100, - step: 0.1, - enabled: isEnabled), - ], - ])) - moduleEditorSection("Local Offset", - detail: L("Emitter-space spawn offset"), - tone: status.tone, - content: moduleEditorRows([ - [ - moduleNumber("Origin X", - moduleFloatBinding(index, fallback: 0, get: { - if case let .shape(module) = $0 { return module.originOffset.x } - return nil - }, set: { - if case var .shape(module) = $0 { - module.originOffset.x = $1 - $0 = .shape(module) - } - }), - min: -1000, - max: 1000, - step: 0.1, - enabled: isEnabled), - moduleNumber("Origin Y", - moduleFloatBinding(index, fallback: 0, get: { - if case let .shape(module) = $0 { return module.originOffset.y } - return nil - }, set: { - if case var .shape(module) = $0 { - module.originOffset.y = $1 - $0 = .shape(module) - } - }), - min: -1000, - max: 1000, - step: 0.1, - enabled: isEnabled), - moduleNumber("Origin Z", - moduleFloatBinding(index, fallback: 0, get: { - if case let .shape(module) = $0 { return module.originOffset.z } - return nil - }, set: { - if case var .shape(module) = $0 { - module.originOffset.z = $1 - $0 = .shape(module) - } - }), - min: -1000, - max: 1000, - step: 0.1, - enabled: isEnabled), - ], - ])) - moduleEditorSection("Box Volume", - detail: L("Half extents for box emission"), - tone: status.tone, - content: moduleEditorRows([ - [ - moduleNumber("Box X", - moduleFloatBinding(index, fallback: 0.5, get: { - if case let .shape(module) = $0 { return module.boxHalfExtents.x } - return nil - }, set: { - if case var .shape(module) = $0 { - module.boxHalfExtents.x = $1 - $0 = .shape(module) - } - }), - min: 0, - max: 1000, - step: 0.1, - enabled: isEnabled), - moduleNumber("Box Y", - moduleFloatBinding(index, fallback: 0.5, get: { - if case let .shape(module) = $0 { return module.boxHalfExtents.y } - return nil - }, set: { - if case var .shape(module) = $0 { - module.boxHalfExtents.y = $1 - $0 = .shape(module) - } - }), - min: 0, - max: 1000, - step: 0.1, - enabled: isEnabled), - moduleNumber("Box Z", - moduleFloatBinding(index, fallback: 0.5, get: { - if case let .shape(module) = $0 { return module.boxHalfExtents.z } - return nil - }, set: { - if case var .shape(module) = $0 { - module.boxHalfExtents.z = $1 - $0 = .shape(module) - } - }), - min: 0, - max: 1000, - step: 0.1, - enabled: isEnabled), - ], - ])) - moduleEditorSection("Cone Volume", - detail: L("Cone radius and height"), - tone: status.tone, - content: moduleEditorRows([ - [ - moduleNumber("Cone R", - moduleFloatBinding(index, fallback: 0, get: { - if case let .shape(module) = $0 { return module.coneRadius } - return nil - }, set: { - if case var .shape(module) = $0 { - module.coneRadius = $1 - $0 = .shape(module) - } - }), - min: 0, - max: 100, - step: 0.1, - enabled: isEnabled), - moduleNumber("Cone H", - moduleFloatBinding(index, fallback: 0, get: { - if case let .shape(module) = $0 { return module.coneHeight } - return nil - }, set: { - if case var .shape(module) = $0 { - module.coneHeight = $1 - $0 = .shape(module) - } - }), - min: 0, - max: 100, - step: 0.1, - enabled: isEnabled), - ], - ])) - }) - } - - private func velocityModuleEditor(index: Int, - module: ParticleEmitterModule, - isEnabled: Bool) -> AnyView { - let status = moduleBackendStatus(module) - return AnyView(Box(direction: .column, alignItems: .stretch, spacing: 8) { - moduleEditorSection("Initial Velocity", - detail: L("Base launch vector applied at spawn"), - tone: status.tone, - content: moduleEditorRows([ - [ - moduleNumber("Vel X", - moduleFloatBinding(index, fallback: 0, get: { - if case let .velocity(module) = $0 { return module.startVelocity.x } - return nil - }, set: { - if case var .velocity(module) = $0 { - module.startVelocity.x = $1 - $0 = .velocity(module) - } - }), - min: -1000, - max: 1000, - step: 0.1, - enabled: isEnabled), - moduleNumber("Vel Y", - moduleFloatBinding(index, fallback: 0, get: { - if case let .velocity(module) = $0 { return module.startVelocity.y } - return nil - }, set: { - if case var .velocity(module) = $0 { - module.startVelocity.y = $1 - $0 = .velocity(module) - } - }), - min: -1000, - max: 1000, - step: 0.1, - enabled: isEnabled), - moduleNumber("Vel Z", - moduleFloatBinding(index, fallback: 0, get: { - if case let .velocity(module) = $0 { return module.startVelocity.z } - return nil - }, set: { - if case var .velocity(module) = $0 { - module.startVelocity.z = $1 - $0 = .velocity(module) - } - }), - min: -1000, - max: 1000, - step: 0.1, - enabled: isEnabled), - ], - ])) - moduleEditorSection("Variation", - detail: L("Random velocity spread and inherited motion"), - tone: status.tone, - content: moduleEditorRows([ - [ - moduleNumber("Rand X", - moduleFloatBinding(index, fallback: 0, get: { - if case let .velocity(module) = $0 { return module.velocityRandomness.x } - return nil - }, set: { - if case var .velocity(module) = $0 { - module.velocityRandomness.x = $1 - $0 = .velocity(module) - } - }), - min: 0, - max: 1000, - step: 0.1, - enabled: isEnabled), - moduleNumber("Rand Y", - moduleFloatBinding(index, fallback: 0, get: { - if case let .velocity(module) = $0 { return module.velocityRandomness.y } - return nil - }, set: { - if case var .velocity(module) = $0 { - module.velocityRandomness.y = $1 - $0 = .velocity(module) - } - }), - min: 0, - max: 1000, - step: 0.1, - enabled: isEnabled), - moduleNumber("Rand Z", - moduleFloatBinding(index, fallback: 0, get: { - if case let .velocity(module) = $0 { return module.velocityRandomness.z } - return nil - }, set: { - if case var .velocity(module) = $0 { - module.velocityRandomness.z = $1 - $0 = .velocity(module) - } - }), - min: 0, - max: 1000, - step: 0.1, - enabled: isEnabled), - moduleNumber("Inherit", - moduleFloatBinding(index, fallback: 0, get: { - if case let .velocity(module) = $0 { return module.velocityInheritance } - return nil - }, set: { - if case var .velocity(module) = $0 { - module.velocityInheritance = $1 - $0 = .velocity(module) - } - }), - min: 0, - max: 10, - step: 0.05, - enabled: isEnabled), - ], - ])) - }) - } - - private func forcesModuleEditor(index: Int, - module: ParticleEmitterModule, - isEnabled: Bool) -> AnyView { - let status = moduleBackendStatus(module) - return AnyView(Box(direction: .column, alignItems: .stretch, spacing: 8) { - moduleEditorSection("Force Sources", - detail: L("Choose analytic force and vector field source"), - tone: status.tone, - content: moduleEditorRows([ - [ - moduleEnum("Force", - moduleValueBinding(index, fallback: .none, get: { - if case let .forces(module) = $0 { return module.forceMode } - return nil - }, set: { - if case var .forces(module) = $0 { - module.forceMode = $1 - $0 = .forces(module) - } - }), - width: 132, - enabled: isEnabled, - label: particleForceModeLabel), - moduleEnum("Vector Field", - moduleValueBinding(index, fallback: .none, get: { - if case let .forces(module) = $0 { return module.vectorFieldMode } - return nil - }, set: { - if case var .forces(module) = $0 { - module.vectorFieldMode = $1 - $0 = .forces(module) - } - }), - width: 132, - enabled: isEnabled, - label: particleVectorFieldModeLabel), - ], - ])) - moduleEditorSection("Gravity", - detail: L("Constant acceleration applied every simulation step"), - tone: status.tone, - content: moduleEditorRows([ - [ - moduleNumber("Gravity X", - moduleFloatBinding(index, fallback: 0, get: { - if case let .forces(module) = $0 { return module.gravity.x } - return nil - }, set: { - if case var .forces(module) = $0 { - module.gravity.x = $1 - $0 = .forces(module) - } - }), - min: -100, - max: 100, - step: 0.1, - enabled: isEnabled), - moduleNumber("Gravity Y", - moduleFloatBinding(index, fallback: 0, get: { - if case let .forces(module) = $0 { return module.gravity.y } - return nil - }, set: { - if case var .forces(module) = $0 { - module.gravity.y = $1 - $0 = .forces(module) - } - }), - min: -100, - max: 100, - step: 0.1, - enabled: isEnabled), - moduleNumber("Gravity Z", - moduleFloatBinding(index, fallback: 0, get: { - if case let .forces(module) = $0 { return module.gravity.z } - return nil - }, set: { - if case var .forces(module) = $0 { - module.gravity.z = $1 - $0 = .forces(module) - } - }), - min: -100, - max: 100, - step: 0.1, - enabled: isEnabled), - ], - ])) - moduleEditorSection("Noise", - detail: L("Procedural turbulence strength and frequency"), - tone: status.tone, - content: moduleEditorRows([ - [ - moduleNumber("Noise", - moduleFloatBinding(index, fallback: 0, get: { - if case let .forces(module) = $0 { return module.noiseStrength } - return nil - }, set: { - if case var .forces(module) = $0 { - module.noiseStrength = $1 - $0 = .forces(module) - } - }), - min: 0, - max: 100, - step: 0.1, - enabled: isEnabled), - moduleNumber("Noise Scale", - moduleFloatBinding(index, fallback: 1, get: { - if case let .forces(module) = $0 { return module.noiseScale } - return nil - }, set: { - if case var .forces(module) = $0 { - module.noiseScale = $1 - $0 = .forces(module) - } - }), - min: 0.0001, - max: 100, - step: 0.1, - enabled: isEnabled), - moduleNumber("Noise Speed", - moduleFloatBinding(index, fallback: 1, get: { - if case let .forces(module) = $0 { return module.noiseSpeed } - return nil - }, set: { - if case var .forces(module) = $0 { - module.noiseSpeed = $1 - $0 = .forces(module) - } - }), - min: 0, - max: 100, - step: 0.1, - enabled: isEnabled), - ], - ])) - moduleEditorSection("Radial / Vortex", - detail: L("Force magnitude, radius, falloff, and local center"), - tone: status.tone, - content: moduleEditorRows([ - [ - moduleNumber("Force", - moduleFloatBinding(index, fallback: 0, get: { - if case let .forces(module) = $0 { return module.forceStrength } - return nil - }, set: { - if case var .forces(module) = $0 { - module.forceStrength = $1 - $0 = .forces(module) - } - }), - min: -1000, - max: 1000, - step: 0.1, - enabled: isEnabled), - moduleNumber("Radius", - moduleFloatBinding(index, fallback: 0, get: { - if case let .forces(module) = $0 { return module.forceRadius } - return nil - }, set: { - if case var .forces(module) = $0 { - module.forceRadius = $1 - $0 = .forces(module) - } - }), - min: 0, - max: 1000, - step: 0.1, - enabled: isEnabled), - moduleNumber("Falloff", - moduleFloatBinding(index, fallback: 1, get: { - if case let .forces(module) = $0 { return module.forceFalloff } - return nil - }, set: { - if case var .forces(module) = $0 { - module.forceFalloff = $1 - $0 = .forces(module) - } - }), - min: 0, - max: 32, - step: 0.1, - enabled: isEnabled), - ], - [ - moduleNumber("Center X", - moduleFloatBinding(index, fallback: 0, get: { - if case let .forces(module) = $0 { return module.forceCenter.x } - return nil - }, set: { - if case var .forces(module) = $0 { - module.forceCenter.x = $1 - $0 = .forces(module) - } - }), - min: -1000, - max: 1000, - step: 0.1, - enabled: isEnabled), - moduleNumber("Center Y", - moduleFloatBinding(index, fallback: 0, get: { - if case let .forces(module) = $0 { return module.forceCenter.y } - return nil - }, set: { - if case var .forces(module) = $0 { - module.forceCenter.y = $1 - $0 = .forces(module) - } - }), - min: -1000, - max: 1000, - step: 0.1, - enabled: isEnabled), - moduleNumber("Center Z", - moduleFloatBinding(index, fallback: 0, get: { - if case let .forces(module) = $0 { return module.forceCenter.z } - return nil - }, set: { - if case var .forces(module) = $0 { - module.forceCenter.z = $1 - $0 = .forces(module) - } - }), - min: -1000, - max: 1000, - step: 0.1, - enabled: isEnabled), - ], - ])) - moduleEditorSection("Vector Field", - detail: L("Field strength, scale, and scroll speed"), - tone: status.tone, - content: moduleEditorRows([ - [ - moduleNumber("VF Strength", - moduleFloatBinding(index, fallback: 0, get: { - if case let .forces(module) = $0 { return module.vectorFieldStrength } - return nil - }, set: { - if case var .forces(module) = $0 { - module.vectorFieldStrength = $1 - $0 = .forces(module) - } - }), - min: -1000, - max: 1000, - step: 0.1, - enabled: isEnabled), - moduleNumber("VF Scale", - moduleFloatBinding(index, fallback: 1, get: { - if case let .forces(module) = $0 { return module.vectorFieldScale } - return nil - }, set: { - if case var .forces(module) = $0 { - module.vectorFieldScale = $1 - $0 = .forces(module) - } - }), - min: 0.0001, - max: 100, - step: 0.1, - enabled: isEnabled), - moduleNumber("VF Scroll", - moduleFloatBinding(index, fallback: 0, get: { - if case let .forces(module) = $0 { return module.vectorFieldScrollSpeed } - return nil - }, set: { - if case var .forces(module) = $0 { - module.vectorFieldScrollSpeed = $1 - $0 = .forces(module) - } - }), - min: 0, - max: 100, - step: 0.1, - enabled: isEnabled), - ], - ])) - }) - } - - private func collisionModuleEditor(index: Int, - module: ParticleEmitterModule, - isEnabled: Bool) -> AnyView { - let status = moduleStatus(module) ?? moduleBackendStatus(module) - return AnyView(Box(direction: .column, alignItems: .stretch, spacing: 8) { - moduleEditorSection("Surface", - detail: L("Collision mode and plane position"), - tone: status.tone, - content: moduleEditorRows([ - [ - moduleEnum("Mode", - moduleValueBinding(index, fallback: .none, get: { - if case let .collision(module) = $0 { return module.collisionMode } - return nil - }, set: { - if case var .collision(module) = $0 { - module.collisionMode = $1 - $0 = .collision(module) - } - }), - width: 132, - enabled: isEnabled, - label: particleCollisionModeLabel), - moduleNumber("Plane Y", - moduleFloatBinding(index, fallback: 0, get: { - if case let .collision(module) = $0 { return module.collisionPlaneY } - return nil - }, set: { - if case var .collision(module) = $0 { - module.collisionPlaneY = $1 - $0 = .collision(module) - } - }), - min: -1000, - max: 1000, - step: 0.1, - enabled: isEnabled), - ], - ])) - moduleEditorSection("Response", - detail: L("Bounce and tangent damping"), - tone: status.tone, - content: moduleEditorRows([ - [ - moduleNumber("Bounce", - moduleFloatBinding(index, fallback: 0, get: { - if case let .collision(module) = $0 { return module.collisionRestitution } - return nil - }, set: { - if case var .collision(module) = $0 { - module.collisionRestitution = $1 - $0 = .collision(module) - } - }), - min: 0, - max: 1, - step: 0.05, - enabled: isEnabled), - moduleNumber("Damping", - moduleFloatBinding(index, fallback: 0, get: { - if case let .collision(module) = $0 { return module.collisionDamping } - return nil - }, set: { - if case var .collision(module) = $0 { - module.collisionDamping = $1 - $0 = .collision(module) - } - }), - min: 0, - max: 1, - step: 0.05, - enabled: isEnabled), - ], - ])) - }) - } - - private func textureSheetModuleEditor(index: Int, - module: ParticleEmitterModule, - isEnabled: Bool) -> AnyView { - let status = moduleStatus(module) ?? moduleBackendStatus(module) - return AnyView(Box(direction: .column, alignItems: .stretch, spacing: 8) { - moduleEditorSection("Playback", - detail: L("Frame selection mode and playback rate"), - tone: status.tone, - content: moduleEditorRows([ - [ - moduleEnum("Playback", - moduleValueBinding(index, fallback: .automatic, get: { - if case let .textureSheet(module) = $0 { return module.playbackMode } - return nil - }, set: { - if case var .textureSheet(module) = $0 { - module.playbackMode = $1 - $0 = .textureSheet(module) - } - }), - width: 140, - enabled: isEnabled, - label: particleTextureSheetPlaybackModeLabel), - moduleNumber("Frame Rate", - moduleFloatBinding(index, fallback: 0, get: { - if case let .textureSheet(module) = $0 { return module.frameRate } - return nil - }, set: { - if case var .textureSheet(module) = $0 { - module.frameRate = $1 - $0 = .textureSheet(module) - } - }), - min: 0, - max: 240, - step: 1, - enabled: isEnabled), - ], - ])) - moduleEditorSection("Sheet Grid", - detail: L("Texture atlas rows, columns, and frame count"), - tone: status.tone, - content: moduleEditorRows([ - [ - moduleNumber("Cols", - moduleIntBinding(index, fallback: 1, get: { - if case let .textureSheet(module) = $0 { return module.columns } - return nil - }, set: { - if case var .textureSheet(module) = $0 { - module.columns = $1 - $0 = .textureSheet(module) - } - }), - min: 1, - max: 64, - step: 1, - enabled: isEnabled), - moduleNumber("Rows", - moduleIntBinding(index, fallback: 1, get: { - if case let .textureSheet(module) = $0 { return module.rows } - return nil - }, set: { - if case var .textureSheet(module) = $0 { - module.rows = $1 - $0 = .textureSheet(module) - } - }), - min: 1, - max: 64, - step: 1, - enabled: isEnabled), - moduleNumber("Frames", - moduleIntBinding(index, fallback: 1, get: { - if case let .textureSheet(module) = $0 { return module.frameCount } - return nil - }, set: { - if case var .textureSheet(module) = $0 { - module.frameCount = $1 - $0 = .textureSheet(module) - } - }), - min: 1, - max: 4096, - step: 1, - enabled: isEnabled), - ], - ])) - moduleEditorSection("Frame Window", - detail: L("Start frame and randomized offset"), - tone: status.tone, - content: moduleEditorRows([ - [ - moduleNumber("Start Frame", - moduleIntBinding(index, fallback: 0, get: { - if case let .textureSheet(module) = $0 { return module.startFrame } - return nil - }, set: { - if case var .textureSheet(module) = $0 { - module.startFrame = $1 - $0 = .textureSheet(module) - } - }), - min: 0, - max: 4096, - step: 1, - enabled: isEnabled), - moduleNumber("Frame Rand", - moduleIntBinding(index, fallback: 0, get: { - if case let .textureSheet(module) = $0 { return module.frameRandomness } - return nil - }, set: { - if case var .textureSheet(module) = $0 { - module.frameRandomness = $1 - $0 = .textureSheet(module) - } - }), - min: 0, - max: 4096, - step: 1, - enabled: isEnabled), - ], - ])) - }) - } - - private func subEmittersModuleEditor(index: Int, - module: ParticleEmitterModule, - isEnabled: Bool) -> AnyView { - let status = moduleStatus(module) ?? moduleBackendStatus(module) - return AnyView(Box(direction: .column, alignItems: .stretch, spacing: 8) { - moduleEditorSection("Legacy Trigger", - detail: L("Single child emitter trigger kept for compatibility"), - tone: status.tone, - content: moduleEditorRows([ - [ - moduleEnum("Trigger", - moduleValueBinding(index, fallback: .none, get: { - if case let .subEmitters(module) = $0 { return module.legacyTrigger } - return nil - }, set: { - if case var .subEmitters(module) = $0 { - module.legacyTrigger = $1 - $0 = .subEmitters(module) - } - }), - width: 132, - enabled: isEnabled, - label: particleSubEmitterTriggerLabel), - moduleNumber("Burst", - moduleIntBinding(index, fallback: 0, get: { - if case let .subEmitters(module) = $0 { return module.legacyBurstCount } - return nil - }, set: { - if case var .subEmitters(module) = $0 { - module.legacyBurstCount = $1 - $0 = .subEmitters(module) - } - }), - min: 0, - max: 100_000, - step: 1, - enabled: isEnabled), - moduleNumber("Probability", - moduleFloatBinding(index, fallback: 1, get: { - if case let .subEmitters(module) = $0 { return module.legacyProbability } - return nil - }, set: { - if case var .subEmitters(module) = $0 { - module.legacyProbability = $1 - $0 = .subEmitters(module) - } - }), - min: 0, - max: 1, - step: 0.05, - enabled: isEnabled), - ], - [ - moduleNumber("Max Depth", - moduleIntBinding(index, fallback: 0, get: { - if case let .subEmitters(module) = $0 { return module.legacyMaxDepth } - return nil - }, set: { - if case var .subEmitters(module) = $0 { - module.legacyMaxDepth = $1 - $0 = .subEmitters(module) - } - }), - min: 0, - max: 16, - step: 1, - enabled: isEnabled), - moduleNumber("Inherit", - moduleFloatBinding(index, fallback: 0, get: { - if case let .subEmitters(module) = $0 { return module.legacyInheritVelocity } - return nil - }, set: { - if case var .subEmitters(module) = $0 { - module.legacyInheritVelocity = $1 - $0 = .subEmitters(module) - } - }), - min: 0, - max: 10, - step: 0.05, - enabled: isEnabled), - moduleNumber("Life", - moduleFloatBinding(index, fallback: 1, get: { - if case let .subEmitters(module) = $0 { return module.legacyLifetime } - return nil - }, set: { - if case var .subEmitters(module) = $0 { - module.legacyLifetime = $1 - $0 = .subEmitters(module) - } - }), - min: 0.0001, - max: 60, - step: 0.1, - enabled: isEnabled), - ], - ])) - moduleEditorSection("Child Rules", - detail: L("Event driven sub-emitter rule list"), - tone: status.tone, - content: AnyView(InspectorParticleSubEmittersValue(binding: moduleSubEmitterRulesBinding(index)) - .opacity(isEnabled ? 1 : 0.66))) - }) - } - - private func moduleEditorSection(_ title: String, - detail: String, - tone: ModuleRuntimeTone, - content: AnyView) -> AnyView { - AnyView(Box(direction: .column, alignItems: .stretch, spacing: 6) { - Box(direction: .column, alignItems: .stretch, spacing: 1) { - Text(L(title)) - .lineLimit(1) - .font(.caption) - .foregroundColor(.onSurface) - Text(detail) - .lineLimit(1) - .font(.caption) - .foregroundColor(.onSurfaceMuted) - } - content - } - .padding(horizontal: 6, vertical: 6) - .background(.surface) - .cornerRadius(4) - .border(tone.background.opacity(0.45), width: 1)) - } - - private func moduleEditorRows(_ rows: [[AnyView]]) -> AnyView { - AnyView(Box(direction: .column, alignItems: .stretch, spacing: 5) { - for fields in rows { - moduleEditorRow(fields) - } - }) - } - - private func moduleEditorRow(_ fields: [AnyView]) -> AnyView { - AnyView(Row(alignment: .center, spacing: 8) { - for field in fields { - field - } - }) - } - - private func moduleNumber(_ label: String, - _ value: Binding, - min: Float?, - max: Float?, - step: Float?, - enabled: Bool) -> AnyView { - AnyView(Box(direction: .column, alignItems: .stretch, spacing: 2) { - Text(L(label)) - .lineLimit(1) - .font(.caption) - .foregroundColor(.onSurfaceMuted) - NumberField(value: value, - decimals: 2, - size: .small, - isEnabled: enabled, - minValue: min, - maxValue: max, - step: step, - showsStepper: true) - .frame(minWidth: 70) - } - .flex()) - } - - private func moduleText(_ label: String, - _ value: Binding, - enabled: Bool) -> AnyView { - AnyView(Box(direction: .column, alignItems: .stretch, spacing: 2) { - Text(L(label)) - .lineLimit(1) - .font(.caption) - .foregroundColor(.onSurfaceMuted) - TextField("", text: value, size: .small, disabled: !enabled, maxLength: 20) - .frame(minWidth: 110) - } - .flex()) - } - - private func moduleCurveEditor(_ label: String, - _ value: Binding, - enabled: Bool) -> AnyView { - AnyView(Box(direction: .column, alignItems: .stretch, spacing: 3) { - Text(L(label)) - .lineLimit(1) - .font(.caption) - .foregroundColor(.onSurfaceMuted) - InspectorParticleCurveValue(binding: value, isEnabled: enabled) - .opacity(enabled ? 1 : 0.72) - } - .padding(horizontal: 0, vertical: 0)) - } - - private func moduleColorRow(_ fields: [AnyView]) -> AnyView { - AnyView(Row(alignment: .center, spacing: 8) { - for field in fields { - field - } - }) - } - - private func moduleColorEditor(_ label: String, - _ value: Binding, - enabled: Bool) -> AnyView { - AnyView(Box(direction: .column, alignItems: .stretch, spacing: 2) { - Text(L(label)) - .lineLimit(1) - .font(.caption) - .foregroundColor(.onSurfaceMuted) - ColorField(color: value, - isEnabled: enabled, - showAlpha: true, - showsInlineValues: false) - .frame(height: 26) - .clipped() - .opacity(enabled ? 1 : 0.72) - } - .flex()) - } - - private func moduleEnum(_ label: String, - _ value: Binding, - width: Float, - enabled: Bool, - label optionLabel: @escaping (Value) -> String) -> AnyView - where Value.AllCases: Collection { - AnyView(Box(direction: .column, alignItems: .stretch, spacing: 2) { - Text(L(label)) - .lineLimit(1) - .font(.caption) - .foregroundColor(.onSurfaceMuted) - EnumField(value: value, - isEnabled: enabled, - width: width, - label: optionLabel) - .frame(minWidth: min(width, 120)) - } - .flex()) - } - - private func moduleFloatBinding(_ index: Int, - fallback: Float, - get: @escaping (ParticleEmitterModuleSettings) -> Float?, - set: @escaping (inout ParticleEmitterModuleSettings, Float) -> Void) - -> Binding { - Binding( - get: { - guard binding.wrappedValue.modules.indices.contains(index) else { return fallback } - return get(binding.wrappedValue.modules[index].settings) ?? fallback - }, - set: { next in - var stack = binding.wrappedValue - guard stack.modules.indices.contains(index) else { return } - set(&stack.modules[index].settings, next) - binding.wrappedValue = stack - } - ) - } - - private func moduleIntBinding(_ index: Int, - fallback: Int, - get: @escaping (ParticleEmitterModuleSettings) -> Int?, - set: @escaping (inout ParticleEmitterModuleSettings, Int) -> Void) - -> Binding { - Binding( - get: { - guard binding.wrappedValue.modules.indices.contains(index) else { return Float(fallback) } - return Float(get(binding.wrappedValue.modules[index].settings) ?? fallback) - }, - set: { next in - var stack = binding.wrappedValue - guard stack.modules.indices.contains(index) else { return } - set(&stack.modules[index].settings, max(0, Int(next.rounded()))) - binding.wrappedValue = stack - } - ) - } - - private func moduleUInt64StringBinding(_ index: Int, - fallback: UInt64, - get: @escaping (ParticleEmitterModuleSettings) -> UInt64?, - set: @escaping (inout ParticleEmitterModuleSettings, UInt64) -> Void) - -> Binding { - Binding( - get: { - guard binding.wrappedValue.modules.indices.contains(index) else { return "\(fallback)" } - return "\(get(binding.wrappedValue.modules[index].settings) ?? fallback)" - }, - set: { next in - let trimmed = next.trimmingCharacters(in: .whitespacesAndNewlines) - guard let value = UInt64(trimmed) else { return } - var stack = binding.wrappedValue - guard stack.modules.indices.contains(index) else { return } - set(&stack.modules[index].settings, value) - binding.wrappedValue = stack - } - ) - } - - private func moduleValueBinding(_ index: Int, - fallback: Value, - get: @escaping (ParticleEmitterModuleSettings) -> Value?, - set: @escaping (inout ParticleEmitterModuleSettings, Value) -> Void) - -> Binding { - Binding( - get: { - guard binding.wrappedValue.modules.indices.contains(index) else { return fallback } - return get(binding.wrappedValue.modules[index].settings) ?? fallback - }, - set: { next in - var stack = binding.wrappedValue - guard stack.modules.indices.contains(index) else { return } - set(&stack.modules[index].settings, next) - binding.wrappedValue = stack - } - ) - } - - private func moduleColorBinding(_ index: Int, - fallback: SIMD4, - get: @escaping (ParticleEmitterModuleSettings) -> SIMD4?, - set: @escaping (inout ParticleEmitterModuleSettings, SIMD4) -> Void) - -> Binding { - Binding( - get: { - guard binding.wrappedValue.modules.indices.contains(index) else { - return Color(r: fallback.x, g: fallback.y, b: fallback.z, a: fallback.w) - } - let color = get(binding.wrappedValue.modules[index].settings) ?? fallback - return Color(r: color.x, g: color.y, b: color.z, a: color.w) - }, - set: { next in - var stack = binding.wrappedValue - guard stack.modules.indices.contains(index) else { return } - let color = SIMD4(clamp01(next.r), - clamp01(next.g), - clamp01(next.b), - clamp01(next.a)) - set(&stack.modules[index].settings, color) - binding.wrappedValue = stack - } - ) - } - - private func clamp01(_ value: Float) -> Float { - max(0, min(1, value)) - } - - private func moduleSubEmitterRulesBinding(_ index: Int) -> Binding<[ParticleSubEmitter]> { - Binding( - get: { - guard binding.wrappedValue.modules.indices.contains(index), - case let .subEmitters(module) = binding.wrappedValue.modules[index].settings else { - return [] - } - return module.rules - }, - set: { next in - var stack = binding.wrappedValue - guard stack.modules.indices.contains(index), - case var .subEmitters(module) = stack.modules[index].settings else { - return - } - module.rules = next - stack.modules[index].settings = .subEmitters(module) - binding.wrappedValue = stack - } - ) - } - - private func particleEmissionShapeLabel(_ shape: ParticleEmissionShape) -> String { - switch shape { - case .sphere: return L("Sphere") - case .box: return L("Box") - case .cone: return L("Cone") - } - } - - private func particleCollisionModeLabel(_ mode: ParticleCollisionMode) -> String { - switch mode { - case .none: return L("None") - case .localPlane: return L("Local Plane") - case .worldPlane: return L("World Plane") - } - } - - private func particleSimulationSpaceLabel(_ space: ParticleSimulationSpace) -> String { - switch space { - case .local: return L("Local") - case .world: return L("World") - } - } - - private func particleSimulationBackendLabel(_ backend: ParticleSimulationBackend) -> String { - switch backend { - case .cpu: return L("CPU") - case .gpuIfSupported: return L("GPU Preferred") - case .gpuRequired: return L("GPU Required") - } - } - - private func particleBlendModeLabel(_ mode: ParticleBlendMode) -> String { - switch mode { - case .alpha: return L("Alpha") - case .additive: return L("Additive") - } - } - - private func particleRenderAlignmentLabel(_ alignment: ParticleRenderAlignment) -> String { - switch alignment { - case .billboard: return L("Billboard") - case .velocity: return L("Velocity") - } - } - - private func particleRenderModeLabel(_ mode: ParticleRenderMode) -> String { - switch mode { - case .billboard: return L("Billboard") - case .ribbon: return L("Ribbon") - } - } - - private func particleSortModeLabel(_ mode: ParticleSortMode) -> String { - switch mode { - case .distanceDescending: return L("Back to Front") - case .distanceAscending: return L("Front to Back") - case .oldestFirst: return L("Oldest First") - case .youngestFirst: return L("Youngest First") - } - } - - private func particleTextureSheetPlaybackModeLabel(_ mode: ParticleTextureSheetPlaybackMode) -> String { - switch mode { - case .automatic: return L("Auto") - case .lifetime: return L("Lifetime") - case .playOnce: return L("Play Once") - case .loop: return L("Loop") - case .singleFrame: return L("Single Frame") - } - } - - private func particleRenderBoundsModeLabel(_ mode: ParticleRenderBoundsMode) -> String { - switch mode { - case .disabled: return L("Disabled") - case .manual: return L("Manual") - case .automatic: return L("Automatic") - } - } - - private func particleForceModeLabel(_ mode: ParticleForceMode) -> String { - switch mode { - case .none: return L("None") - case .radial: return L("Radial") - case .vortex: return L("Vortex") - } - } - - private func particleVectorFieldModeLabel(_ mode: ParticleVectorFieldMode) -> String { - switch mode { - case .none: return L("None") - case .uniform: return L("Uniform") - case .curl: return L("Curl") - } - } - - private func particleSubEmitterTriggerLabel(_ trigger: ParticleSubEmitterTrigger) -> String { - switch trigger { - case .none: return L("None") - case .death: return L("Death") - case .collision: return L("Collision") - } - } - - private func moduleDetail(_ settings: ParticleEmitterModuleSettings) -> String { - switch settings { - case let .emission(module): - return "\(fmt(module.emissionRate))/s · speed \(fmt(module.simulationSpeed)) · max \(module.maxParticles)" - case let .shape(module): - return "\(module.emissionShape.rawValue) · r \(fmt(module.spawnRadius))" - case let .velocity(module): - return "v \(vec(module.startVelocity)) · inherit \(fmt(module.velocityInheritance))" - case let .forces(module): - return "\(module.forceMode.rawValue) · noise \(fmt(module.noiseStrength))" - case let .collision(module): - return "\(module.collisionMode.rawValue) · bounce \(fmt(module.collisionRestitution))" - case let .appearance(module): - return "life \(fmt(module.lifetime)) · size \(fmt(module.startSize)) -> \(fmt(module.endSize))" - case let .textureSheet(module): - return "\(module.columns)x\(module.rows) · \(module.playbackMode.rawValue)" - case let .renderer(module): - return "\(module.renderMode.rawValue) · \(module.sortMode.rawValue) · p\(module.renderSortPriority)" - case let .trails(module): - return "trail \(fmt(module.trailLength))s · \(module.trailSegments) samples" - case let .subEmitters(module): - return "\(module.rules.count) rules · legacy \(module.legacyTrigger.rawValue)" - case let .gpuSimulation(module): - return "\(module.simulationBackend.rawValue) · \(module.workgroupSize) threads" - } - } - - private func fmt(_ value: Float) -> String { - let rounded = (value * 100).rounded() / 100 - if rounded == Float(Int(rounded)) { - return "\(Int(rounded))" - } - return "\(rounded)" - } - - private func formatMs(_ seconds: Float) -> String { - let ms = max(0, seconds) * 1000 - return "\(fmt(ms))ms" - } - - private func vec(_ value: SIMD3) -> String { - "(\(fmt(value.x)), \(fmt(value.y)), \(fmt(value.z)))" - } - } - - private struct InspectorBooleanValue: View { - let binding: Binding - - var body: some View { - Row(alignment: .center, spacing: 6) { - Checkbox(isOn: binding) - Text(binding.wrappedValue ? L("On") : L("Off")) - .font(.caption) - .foregroundColor(.onSurfaceVariant) - .flex() - } - } - } - - private struct InspectorNumberValue: View { - let binding: Binding - let minValue: Float? - let maxValue: Float? - let step: Float? - let showsStepper: Bool - - var body: some View { - NumberField(value: binding, - decimals: 2, - size: .small, - minValue: minValue, - maxValue: maxValue, - step: step, - showsStepper: showsStepper) - .frame(minWidth: 96) - .flex() - } - } - - private struct InspectorTextValue: View { - let identity: String - let binding: Binding - - var body: some View { - // Draft-while-editing: model normalization (empty entity name → - // fallback, clip lookups) must not rewrite the text mid-edit. - CommitOnBlurTextField(identity: identity, text: binding, size: .small) - .flex() - .clipped() - } - } - - private struct InspectorVectorValue: View { - let x: Binding - let y: Binding - let z: Binding - - var body: some View { - Vec3Field(x: x, y: y, z: z, decimals: 2, size: .small) - .flex(1, shrink: 1, basis: 0) - .clipped() - } - } - - private struct InspectorColorValue: View { - let binding: Binding - - var body: some View { - ColorField(color: binding, - showAlpha: false, - showsInlineValues: true) - .flex() - .clipped() - } - } - - private struct InspectorLightTypeValue: View { - let binding: Binding - - var body: some View { - EnumField(value: binding, width: 150) { type in - switch type { - case .directional: return L("Directional") - case .point: return L("Point") - case .spot: return L("Spot") - } - } - } - } - - private struct InspectorRigidBodyMotionValue: View { - let binding: Binding - - var body: some View { - EnumField(value: binding, width: 150) { type in - switch type { - case .static: return L("Static") - case .dynamic: return L("Dynamic") - case .kinematic: return L("Kinematic") - } - } - } - } - - private struct InspectorColliderShapeKindValue: View { - let binding: Binding - - var body: some View { - EnumField(value: binding, width: 150) { kind in - switch kind { - case .box: return L("Box") - case .sphere: return L("Sphere") - case .capsule: return L("Capsule") - case .mesh: return L("Mesh") - case .convex: return L("Convex") - } - } - } - } - - private struct InspectorParticleEmissionShapeValue: View { - let binding: Binding - - var body: some View { - EnumField(value: binding, width: 150) { shape in - switch shape { - case .sphere: return L("Sphere") - case .box: return L("Box") - case .cone: return L("Cone") - } - } - } - } - - private struct InspectorParticleCollisionModeValue: View { - let binding: Binding - - var body: some View { - EnumField(value: binding, width: 150) { mode in - switch mode { - case .none: return L("None") - case .localPlane: return L("Local Plane") - case .worldPlane: return L("World Plane") - } - } - } - } - - private struct InspectorParticleSimulationSpaceValue: View { - let binding: Binding - - var body: some View { - EnumField(value: binding, width: 150) { space in - switch space { - case .local: return L("Local") - case .world: return L("World") - } - } - } - } - - private struct InspectorParticleSimulationBackendValue: View { - let binding: Binding - - var body: some View { - EnumField(value: binding, width: 170) { backend in - switch backend { - case .cpu: return L("CPU") - case .gpuIfSupported: return L("GPU Preferred") - case .gpuRequired: return L("GPU Required") - } - } - } - } - - private struct InspectorParticleCurveValue: View { - let binding: Binding - var isEnabled: Bool = true - @State private var selectedKeyIndex: Int? = nil - - var body: some View { - Box(direction: .column, alignItems: .stretch, spacing: 7) { - toolbar - if case .keyframes(let keyframes) = binding.wrappedValue { - ParticleCurvePreview(binding: binding, - selectedKeyIndex: $selectedKeyIndex, - isEnabled: isEnabled) - .frame(height: ParticleCurveEditorLayout.previewHeight) - ParticleCurveKeyframeRows(binding: binding, - keyframes: keyframes, - selectedKeyIndex: $selectedKeyIndex, - isEnabled: isEnabled) - } - } - .padding(horizontal: 8, vertical: 8) - .background(.surfaceSunken) - .cornerRadius(6) - .border(.border, width: 1) - .frame(height: fixedHeight) - .clipped() - } - - private var fixedHeight: Float { - if case .keyframes(let keyframes) = binding.wrappedValue { - return ParticleCurveEditorLayout.valueHeight(keyframeCount: keyframes.count) - } - return ParticleCurveEditorLayout.linearValueHeight - } - - private var toolbar: some View { - Row(alignment: .center, spacing: 8) { - Select(selection: binding, - options: options, - isEnabled: isEnabled, - width: 170) - if case .keyframes(let keyframes) = binding.wrappedValue { - Button(L("Add"), isEnabled: isEnabled) { appendKeyframe(to: keyframes) } - .buttonStyle(.secondary) - .frame(width: 52, height: 24) - Button(L("Reset"), isEnabled: isEnabled) { - binding.wrappedValue = .keyframes(Self.defaultKeyframes) - selectedKeyIndex = nil - } - .buttonStyle(.ghost) - .frame(width: 58, height: 24) - } - Spacer(minLength: 0) - } - .frame(height: ParticleCurveEditorLayout.toolbarHeight) - } - - private var options: [SelectOption] { - var values: [SelectOption] = [ - SelectOption(value: .constant(1), label: L("Constant")), - SelectOption(value: .linear, label: L("Linear")), - SelectOption(value: .easeIn, label: L("Ease In")), - SelectOption(value: .easeOut, label: L("Ease Out")), - SelectOption(value: .easeInOut, label: L("Ease In-Out")), - ] - if case .constant(let value) = binding.wrappedValue, value != 1 { - values.append(SelectOption(value: binding.wrappedValue, - label: "\(L("Constant")) \(value)")) - } - if case .keyframes(let keyframes) = binding.wrappedValue { - values.append(SelectOption(value: binding.wrappedValue, - label: "\(L("Keyframes")) (\(keyframes.count))")) - } else { - values.append(SelectOption(value: .keyframes(Self.defaultKeyframes), - label: L("Keyframes"))) - } - return values - } - - private static let defaultKeyframes: [ParticleCurveKeyframe] = [ - ParticleCurveKeyframe(time: 0, value: 0), - ParticleCurveKeyframe(time: 1, value: 1), - ] - - private func appendKeyframe(to keyframes: [ParticleCurveKeyframe]) { - let sorted = keyframes.sortedByTimeStable() - let insert: ParticleCurveKeyframe - if sorted.count >= 2 { - let widestPair = zip(sorted.indices.dropLast(), sorted.indices.dropFirst()) - .max { lhs, rhs in - let leftSpan = sorted[lhs.1].time - sorted[lhs.0].time - let rightSpan = sorted[rhs.1].time - sorted[rhs.0].time - return leftSpan < rightSpan - } - if let pair = widestPair { - let lower = sorted[pair.0] - let upper = sorted[pair.1] - let time = (lower.time + upper.time) * 0.5 - let value = (lower.value + upper.value) * 0.5 - insert = ParticleCurveKeyframe(time: time, value: value) - } else { - insert = ParticleCurveKeyframe(time: 0.5, value: 0.5) - } - } else { - insert = ParticleCurveKeyframe(time: 0.5, value: 0.5) - } - let next = (sorted + [insert]).sortedByTimeStable() - binding.wrappedValue = .keyframes(next) - selectedKeyIndex = next.nearestIndex(to: insert) - } - - } - - private struct InspectorParticleBlendModeValue: View { - let binding: Binding - - var body: some View { - EnumField(value: binding, width: 150) { mode in - switch mode { - case .alpha: return L("Alpha") - case .additive: return L("Additive") - } - } - } - } - - private struct InspectorParticleRenderAlignmentValue: View { - let binding: Binding - - var body: some View { - EnumField(value: binding, width: 150) { alignment in - switch alignment { - case .billboard: return L("Billboard") - case .velocity: return L("Velocity") - } - } - } - } - - private struct InspectorParticleRenderModeValue: View { - let binding: Binding - - var body: some View { - EnumField(value: binding, width: 150) { mode in - switch mode { - case .billboard: return L("Billboard") - case .ribbon: return L("Ribbon") - } - } - } - } - - private struct InspectorParticleSortModeValue: View { - let binding: Binding - - var body: some View { - EnumField(value: binding, width: 190) { mode in - switch mode { - case .distanceDescending: return L("Back to Front") - case .distanceAscending: return L("Front to Back") - case .oldestFirst: return L("Oldest First") - case .youngestFirst: return L("Youngest First") - } - } - } - } - - private struct InspectorParticleTextureSheetPlaybackModeValue: View { - let binding: Binding - - var body: some View { - EnumField(value: binding, width: 170) { mode in - switch mode { - case .automatic: return L("Auto") - case .lifetime: return L("Lifetime") - case .playOnce: return L("Play Once") - case .loop: return L("Loop") - case .singleFrame: return L("Single Frame") - } - } - } - } - - private struct InspectorParticleRenderBoundsModeValue: View { - let binding: Binding - - var body: some View { - EnumField(value: binding, width: 150) { mode in - switch mode { - case .disabled: return L("Disabled") - case .manual: return L("Manual") - case .automatic: return L("Automatic") - } - } - } - } - - private struct InspectorParticleForceModeValue: View { - let binding: Binding - - var body: some View { - EnumField(value: binding, width: 150) { mode in - switch mode { - case .none: return L("None") - case .radial: return L("Radial") - case .vortex: return L("Vortex") - } - } - } - } - - private struct InspectorParticleVectorFieldModeValue: View { - let binding: Binding - - var body: some View { - EnumField(value: binding, width: 150) { mode in - switch mode { - case .none: return L("None") - case .uniform: return L("Uniform") - case .curl: return L("Curl") - } - } - } - } - - private struct InspectorParticleSubEmitterTriggerValue: View { - let binding: Binding - - var body: some View { - EnumField(value: binding, width: 150) { trigger in - switch trigger { - case .none: return L("None") - case .death: return L("Death") - case .collision: return L("Collision") - } - } - } - } - - private struct InspectorParticleSubEmittersValue: View { - let binding: Binding<[ParticleSubEmitter]> - - var body: some View { - Box(direction: .column, alignItems: .stretch, spacing: 8) { - Row(alignment: .center, spacing: 8) { - Text("\(binding.wrappedValue.count) \(L("Rules"))") - .font(.caption) - .foregroundColor(.onSurfaceMuted) - Spacer(minLength: 0) - Button(L("Add")) { appendRule() } - .buttonStyle(.secondary) - .frame(width: 56, height: 24) - } - .frame(height: ParticleSubEmitterEditorLayout.toolbarHeight) - - if binding.wrappedValue.isEmpty { - Box(direction: .column, alignItems: .center, justifyContent: .center) { - Text(L("No sub-emitter rules")) - .font(.caption) - .foregroundColor(.onSurfaceMuted) - } - .frame(height: ParticleSubEmitterEditorLayout.emptyHeight) - .background(.surface) - .cornerRadius(6) - .border(.border, width: 1) - } else { - ScrollView(.vertical, - consumePolicy: .always, - scrollbarGutter: .stable) { - Box(direction: .column, alignItems: .stretch, spacing: ParticleSubEmitterEditorLayout.ruleGap) { - for index in binding.wrappedValue.indices { - ParticleSubEmitterRuleCard(binding: binding, index: index) - } - } - } - .frame(height: ParticleSubEmitterEditorLayout.listHeight(ruleCount: binding.wrappedValue.count)) - } - } - .padding(horizontal: 8, vertical: 8) - .background(.surfaceSunken) - .cornerRadius(6) - .border(.border, width: 1) - .frame(height: ParticleSubEmitterEditorLayout.valueHeight(ruleCount: binding.wrappedValue.count)) - .clipped() - } - - private func appendRule() { - var next = binding.wrappedValue - next.append(ParticleSubEmitter(trigger: .death, - burstCount: 8, - probability: 1, - maxDepth: 1, - inheritVelocity: 0.25, - lifetime: 0.45, - startVelocity: SIMD3(0, 1.5, 0), - velocityRandomness: SIMD3(0.5, 0.5, 0.5), - startSize: 0.25, - endSize: 0, - startColor: SIMD4(1, 1, 1, 1), - endColor: SIMD4(1, 1, 1, 0))) - binding.wrappedValue = next - } - } - - private struct ParticleSubEmitterRuleCard: View { - let binding: Binding<[ParticleSubEmitter]> - let index: Int - - var body: some View { - Box(direction: .column, alignItems: .stretch, spacing: 7) { - Row(alignment: .center, spacing: 8) { - Text("#\(index + 1)") - .font(.bodyStrong) - .foregroundColor(.onSurface) - Spacer(minLength: 0) - Button(icon: .resource(UICommonIcons.close), - size: 10, - tooltip: L("Remove rule"), - action: removeRule) - .buttonStyle(.ghost) - .frame(width: 24, height: 24) - } - .frame(height: 24) - - Row(alignment: .center, spacing: 8) { - labeledCompactField(L("Trigger")) { - EnumField(value: triggerBinding, width: 112) { trigger in - switch trigger { - case .none: return L("None") - case .death: return L("Death") - case .collision: return L("Collision") - } - } - } - labeledCompactField(L("Count")) { - NumberField(value: intBinding(\.burstCount), - decimals: 0, - size: .small, - minValue: 0, - maxValue: 10_000, - step: 1, - showsStepper: true) - } - labeledCompactField(L("Chance")) { - NumberField(value: floatBinding(\.probability), - decimals: 2, - size: .small, - minValue: 0, - maxValue: 1, - step: 0.05, - showsStepper: true) - } - } - - Row(alignment: .center, spacing: 8) { - labeledCompactField(L("Depth")) { - NumberField(value: intBinding(\.maxDepth), - decimals: 0, - size: .small, - minValue: 0, - maxValue: 16, - step: 1, - showsStepper: true) - } - labeledCompactField(L("Inherit")) { - NumberField(value: floatBinding(\.inheritVelocity), - decimals: 2, - size: .small, - minValue: 0, - maxValue: 10, - step: 0.05, - showsStepper: true) - } - labeledCompactField(L("Life")) { - NumberField(value: floatBinding(\.lifetime), - decimals: 2, - size: .small, - minValue: 0.0001, - maxValue: 60, - step: 0.05, - showsStepper: true) - } - } - - labeledWideField(L("Velocity")) { - Vec3Field(x: vectorBinding(\.startVelocity, axis: 0), - y: vectorBinding(\.startVelocity, axis: 1), - z: vectorBinding(\.startVelocity, axis: 2), - decimals: 2, - size: .small) - } - labeledWideField(L("Random")) { - Vec3Field(x: vectorBinding(\.velocityRandomness, axis: 0), - y: vectorBinding(\.velocityRandomness, axis: 1), - z: vectorBinding(\.velocityRandomness, axis: 2), - decimals: 2, - size: .small) - } - - Row(alignment: .center, spacing: 8) { - labeledCompactField(L("Start Size")) { - NumberField(value: floatBinding(\.startSize), - decimals: 2, - size: .small, - minValue: 0, - maxValue: 100, - step: 0.05, - showsStepper: true) - } - labeledCompactField(L("End Size")) { - NumberField(value: floatBinding(\.endSize), - decimals: 2, - size: .small, - minValue: 0, - maxValue: 100, - step: 0.05, - showsStepper: true) - } - } - - Row(alignment: .center, spacing: 8) { - labeledColorField(L("Start Color")) { - ColorField(color: colorBinding(isStart: true), - showAlpha: true, - showsInlineValues: false) - } - labeledColorField(L("End Color")) { - ColorField(color: colorBinding(isStart: false), - showAlpha: true, - showsInlineValues: false) - } - } - } - .padding(horizontal: 8, vertical: 8) - .background(.surface) - .cornerRadius(6) - .border(.border, width: 1) - .frame(height: ParticleSubEmitterEditorLayout.ruleHeight) - .clipped() - } - - private func labeledCompactField(_ title: String, - @ViewBuilder content: () -> Content) -> some View { - Box(direction: .column, alignItems: .stretch, spacing: 3) { - Text(title) - .lineLimit(1) + Row(alignment: .center, spacing: 6) { + Checkbox(isOn: binding) + Text(binding.wrappedValue ? L("On") : L("Off")) .font(.caption) - .foregroundColor(.onSurfaceMuted) - content() - .frame(height: 24) - .clipped() + .foregroundColor(.onSurfaceVariant) + .flex() } - .flex(1, shrink: 1, basis: 0) } + } - private func labeledWideField(_ title: String, - @ViewBuilder content: () -> Content) -> some View { - Row(alignment: .center, spacing: 8) { - Text(title) - .lineLimit(1) - .font(.caption) - .foregroundColor(.onSurfaceMuted) - .frame(width: 66) - content() - .flex(1, shrink: 1, basis: 0) - .clipped() - } - .frame(height: 28) - } + private struct InspectorNumberValue: View { + let binding: Binding + let minValue: Float? + let maxValue: Float? + let step: Float? + let showsStepper: Bool - private func labeledColorField(_ title: String, - @ViewBuilder content: () -> Content) -> some View { - Box(direction: .column, alignItems: .stretch, spacing: 3) { - Text(title) - .lineLimit(1) - .font(.caption) - .foregroundColor(.onSurfaceMuted) - content() - .frame(height: 26) - .clipped() - } - .flex(1, shrink: 1, basis: 0) + var body: some View { + NumberField(value: binding, + decimals: 2, + size: .small, + minValue: minValue, + maxValue: maxValue, + step: step, + showsStepper: showsStepper) + .frame(minWidth: 96) + .flex() } + } - private var rule: ParticleSubEmitter? { - guard binding.wrappedValue.indices.contains(index) else { return nil } - return binding.wrappedValue[index] - } + private struct InspectorTextValue: View { + let identity: String + let binding: Binding - private var triggerBinding: Binding { - Binding( - get: { rule?.trigger ?? .none }, - set: { value in updateRule { $0.trigger = value } } - ) + var body: some View { + // Draft-while-editing: model normalization (empty entity name → + // fallback, clip lookups) must not rewrite the text mid-edit. + CommitOnBlurTextField(identity: identity, text: binding, size: .small) + .flex() + .clipped() } + } - private func floatBinding(_ keyPath: WritableKeyPath) -> Binding { - Binding( - get: { rule?[keyPath: keyPath] ?? 0 }, - set: { value in updateRule { $0[keyPath: keyPath] = value } } - ) - } + private struct InspectorVectorValue: View { + let x: Binding + let y: Binding + let z: Binding - private func intBinding(_ keyPath: WritableKeyPath) -> Binding { - Binding( - get: { Float(rule?[keyPath: keyPath] ?? 0) }, - set: { value in updateRule { $0[keyPath: keyPath] = Int(value.rounded()) } } - ) + var body: some View { + Vec3Field(x: x, y: y, z: z, decimals: 2, size: .small) + .flex(1, shrink: 1, basis: 0) + .clipped() } + } - private func vectorBinding(_ keyPath: WritableKeyPath>, - axis: Int) -> Binding { - Binding( - get: { - guard let rule, axis >= 0 && axis < 3 else { return 0 } - return rule[keyPath: keyPath][axis] - }, - set: { value in - guard axis >= 0 && axis < 3 else { return } - updateRule { $0[keyPath: keyPath][axis] = value } - } - ) + private struct InspectorColorValue: View { + let binding: Binding + + var body: some View { + ColorField(color: binding, + showAlpha: false, + showsInlineValues: true) + .flex() + .clipped() } + } - private func colorBinding(isStart: Bool) -> Binding { - Binding( - get: { - let color = rule.map { isStart ? $0.startColor : $0.endColor } - ?? SIMD4(1, 1, 1, 1) - return Color(r: color.x, g: color.y, b: color.z, a: color.w) - }, - set: { value in - let color = SIMD4(clamp01(value.r), - clamp01(value.g), - clamp01(value.b), - clamp01(value.a)) - updateRule { - if isStart { - $0.startColor = color - } else { - $0.endColor = color - } - } + private struct InspectorLightTypeValue: View { + let binding: Binding + + var body: some View { + EnumField(value: binding, width: 150) { type in + switch type { + case .directional: return L("Directional") + case .point: return L("Point") + case .spot: return L("Spot") } - ) + } } + } - private func updateRule(_ mutate: (inout ParticleSubEmitter) -> Void) { - guard binding.wrappedValue.indices.contains(index) else { return } - var next = binding.wrappedValue - mutate(&next[index]) - next[index] = sanitized(next[index]) - binding.wrappedValue = next - } + private struct InspectorRigidBodyMotionValue: View { + let binding: Binding - private func removeRule() { - guard binding.wrappedValue.indices.contains(index) else { return } - var next = binding.wrappedValue - next.remove(at: index) - binding.wrappedValue = next + var body: some View { + EnumField(value: binding, width: 150) { type in + switch type { + case .static: return L("Static") + case .dynamic: return L("Dynamic") + case .kinematic: return L("Kinematic") + } + } } + } - private func sanitized(_ rule: ParticleSubEmitter) -> ParticleSubEmitter { - ParticleSubEmitter(trigger: rule.trigger, - burstCount: rule.burstCount, - probability: rule.probability, - maxDepth: rule.maxDepth, - inheritVelocity: rule.inheritVelocity, - lifetime: rule.lifetime, - startVelocity: rule.startVelocity, - velocityRandomness: rule.velocityRandomness, - startSize: rule.startSize, - endSize: rule.endSize, - startColor: rule.startColor, - endColor: rule.endColor) - } + private struct InspectorColliderShapeKindValue: View { + let binding: Binding - private func clamp01(_ value: Float) -> Float { - max(0, min(1, value)) + var body: some View { + EnumField(value: binding, width: 150) { kind in + switch kind { + case .box: return L("Box") + case .sphere: return L("Sphere") + case .capsule: return L("Capsule") + case .mesh: return L("Mesh") + case .convex: return L("Convex") + } + } } } + + private func propertySections(_ sections: [EditorInspectorSection], collapsedIDs: Set, - entityID: UInt64?, - particleDiagnostics: InspectorParticleRuntimeDiagnostics) -> [PropertyGridSection] { + entityID: UInt64?) -> [PropertyGridSection] { func row(for field: EditorInspectorField, sectionID: String) -> PropertyGridRow { PropertyGridRow(id: field.id, label: field.label, rowHeight: field.value.preferredRowHeight(defaultHeight: 28), layout: field.value.preferredRowLayout) { fieldView(field.value, - identity: "\(entityID.map(String.init) ?? "none")/\(sectionID)/\(field.id)", - particleDiagnostics: particleDiagnostics) + identity: "\(entityID.map(String.init) ?? "none")/\(sectionID)/\(field.id)") } } return sections.flatMap { section -> [PropertyGridSection] in let startsCollapsed = collapsedIDs.contains(section.id) - if section.id == "particle-emitter", - let moduleStackField = section.fields.first(where: { $0.id == Self.particleModuleStackFieldID }) { + if section.id == "particle-emitter" { let primaryFields = section.fields.filter(Self.isPrimaryParticleInspectorField) - let legacyFields = section.fields.filter(Self.isLegacyParticleInspectorField) - var result = [ + let remainingFields = section.fields.filter { + $0.id != Self.particleModuleStackFieldID + && !Self.isPrimaryParticleInspectorField($0) + } + return [ PropertyGridSection( id: section.id, title: section.title, - rows: ([moduleStackField] + primaryFields).map { row(for: $0, sectionID: section.id) }, + rows: (primaryFields + remainingFields).map { row(for: $0, sectionID: section.id) }, isCollapsible: true, startsCollapsed: startsCollapsed ) ] - if !legacyFields.isEmpty { - let compatibilityID = "\(section.id)-compatibility" - result.append( - PropertyGridSection( - id: compatibilityID, - title: L("Advanced Compatibility"), - rows: legacyFields.map { row(for: $0, sectionID: compatibilityID) }, - isCollapsible: true, - startsCollapsed: true - ) - ) - } - return result } return [ @@ -4591,15 +290,8 @@ struct InspectorPanel: View { primaryParticleInspectorFieldIDs.contains(field.id) } - private static func isLegacyParticleInspectorField(_ field: EditorInspectorField) -> Bool { - field.id.hasPrefix("particle-") - && field.id != particleModuleStackFieldID - && !isPrimaryParticleInspectorField(field) - } - private func fieldView(_ value: EditorInspectorFieldValue, - identity: String, - particleDiagnostics: InspectorParticleRuntimeDiagnostics) -> some View { + identity: String) -> some View { switch value { case let .readOnly(text): return AnyView(InspectorReadOnlyValue(text: text)) @@ -4661,9 +353,8 @@ struct InspectorPanel: View { return AnyView(InspectorParticleSubEmitterTriggerValue(binding: binding)) case let .particleSubEmitters(binding): return AnyView(InspectorParticleSubEmittersValue(binding: binding)) - case let .particleModuleStack(binding): - return AnyView(InspectorParticleModuleStackValue(binding: binding, - diagnostics: particleDiagnostics)) + case .particleModuleStack: + return AnyView(EmptyView()) case let .asset(binding, acceptedKinds, placeholder): return AnyView(AssetRefField(value: assetRefBinding(binding), activePayload: activeAssetDropPayload, @@ -4762,7 +453,7 @@ private extension EditorAsset { private extension EditorInspectorFieldValue { var preferredRowLayout: PropertyGridRowLayout { switch self { - case .particleCurve, .particleSubEmitters, .particleModuleStack: + case .particleCurve, .particleSubEmitters: return .fullWidth default: return .twoColumn @@ -4782,62 +473,8 @@ private extension EditorInspectorFieldValue { return max(defaultHeight, ParticleCurveEditorLayout.linearRowHeight) case let .particleSubEmitters(binding): return max(defaultHeight, ParticleSubEmitterEditorLayout.rowHeight(ruleCount: binding.wrappedValue.count)) - case let .particleModuleStack(binding): - let stack = binding.wrappedValue - let headerHeight: Float = 62 - let listPaddingHeight: Float = 0 - let advancedSeparatorHeight: Float = 34 - let rowHeight: Float = 58 - let moduleEditorRowHeight: Float = 46 - let moduleParameterChromeHeight: Float = 34 - let moduleGPUBackendPanelHeight: Float = 90 - let moduleExpandedDiagnosticsHeight: Float = 36 - let moduleIssueHeaderHeight: Float = 18 - let moduleIssueRowHeight: Float = 30 - let advancedModuleIDs: Set = [ - "textureSheet", - "trails", - "subEmitters", - "gpuSimulation", - ] - let stackIssueCounts = Dictionary( - grouping: stack.validationIssues(), - by: \.moduleID - ).mapValues(\.count) - let groupCount: Float = stack.modules.contains { advancedModuleIDs.contains($0.id) } ? 2 : 1 - let expandedEditorHeight = stack.modules.reduce(Float(0)) { total, module in - guard module.isExpanded else { return total } - let issueCount = Float(stackIssueCounts[module.id] ?? 0) - let issueHeight = issueCount > 0 - ? moduleIssueHeaderHeight + issueCount * moduleIssueRowHeight - : 0 - let gpuBackendHeight: Float - if case .gpuSimulation = module.settings { - gpuBackendHeight = moduleGPUBackendPanelHeight - } else { - gpuBackendHeight = 0 - } - return total - + 8 - + moduleParameterChromeHeight - + particleModuleEditorHeight(module.settings, rowHeight: moduleEditorRowHeight) - + gpuBackendHeight - + issueHeight - + moduleExpandedDiagnosticsHeight - } - let groupSpacing: Float = max(0, groupCount - 1) * 4 - let rowSpacing: Float = Float(max(0, stack.modules.count - Int(groupCount))) * 3 - let cardPadding: Float = 16 - return max(defaultHeight, - headerHeight - + listPaddingHeight - + (groupCount > 1 ? advancedSeparatorHeight : 0) - + Float(stack.modules.count) * rowHeight - + expandedEditorHeight - + groupSpacing - + rowSpacing - + cardPadding - + 8) + case .particleModuleStack: + return nil case let .json(_, minHeight): return max(defaultHeight, minHeight + 34) default: @@ -4846,626 +483,6 @@ private extension EditorInspectorFieldValue { } } -private func particleModuleEditorHeight(_ settings: ParticleEmitterModuleSettings, - rowHeight: Float) -> Float { - let sectionHeaderHeight: Float = 28 - let sectionPadding: Float = 12 - let sectionSpacing: Float = 8 - func sectionHeight(rowCount: Int) -> Float { - sectionHeaderHeight - + sectionPadding - + Float(rowCount) * rowHeight - + Float(max(0, rowCount - 1)) * 5 - + 6 - } - - if case let .emission(module) = settings { - return 4 * rowHeight - + 3 * 5 - + 2 * 8 - + particleModuleCurveEditorHeight(module.emissionRateCurve) - + particleModuleCurveEditorHeight(module.distanceEmissionRateCurve) - } - if case let .appearance(module) = settings { - return 5 * rowHeight - + 4 * 5 - + 3 * 8 - + rowHeight - + particleModuleCurveEditorHeight(module.sizeCurve) - + particleModuleCurveEditorHeight(module.colorCurve) - } - if case let .subEmitters(module) = settings { - let rulesSectionHeight = sectionHeaderHeight - + sectionPadding - + ParticleSubEmitterEditorLayout.valueHeight(ruleCount: module.rules.count) - + 6 - return sectionHeight(rowCount: 2) - + sectionSpacing - + rulesSectionHeight - } - if case .shape = settings { - return sectionHeight(rowCount: 1) - + sectionSpacing - + sectionHeight(rowCount: 1) - + sectionSpacing - + sectionHeight(rowCount: 1) - + sectionSpacing - + sectionHeight(rowCount: 1) - } - if case .velocity = settings { - return sectionHeight(rowCount: 1) - + sectionSpacing - + sectionHeight(rowCount: 1) - } - if case .forces = settings { - return sectionHeight(rowCount: 1) - + sectionSpacing - + sectionHeight(rowCount: 1) - + sectionSpacing - + sectionHeight(rowCount: 1) - + sectionSpacing - + sectionHeight(rowCount: 2) - + sectionSpacing - + sectionHeight(rowCount: 1) - } - if case .collision = settings { - return sectionHeight(rowCount: 1) - + sectionSpacing - + sectionHeight(rowCount: 1) - } - if case .textureSheet = settings { - return sectionHeight(rowCount: 1) - + sectionSpacing - + sectionHeight(rowCount: 1) - + sectionSpacing - + sectionHeight(rowCount: 1) - } - if case .renderer = settings { - return sectionHeight(rowCount: 2) - + sectionSpacing - + sectionHeight(rowCount: 2) - + sectionSpacing - + sectionHeight(rowCount: 1) - } - if case .trails = settings { - return sectionHeight(rowCount: 1) - + sectionSpacing - + sectionHeight(rowCount: 2) - + sectionSpacing - + sectionHeight(rowCount: 1) - } - if case .gpuSimulation = settings { - return sectionHeight(rowCount: 1) - + sectionSpacing - + sectionHeight(rowCount: 1) - } - return Float(particleModuleEditorRowCount(settings)) * rowHeight -} - -private func particleModuleCurveEditorHeight(_ curve: ParticleCurve) -> Float { - let valueHeight: Float - if case let .keyframes(keyframes) = curve { - valueHeight = ParticleCurveEditorLayout.valueHeight(keyframeCount: keyframes.count) - } else { - valueHeight = ParticleCurveEditorLayout.linearValueHeight - } - return ParticleCurveEditorLayout.propertyGridLabelHeight + 3 + valueHeight -} - -private func particleModuleEditorRowCount(_ settings: ParticleEmitterModuleSettings) -> Int { - switch settings { - case .emission: - return 3 - case .forces: - return 6 - case .appearance: - return 5 - case .renderer, .trails: - return 5 - case .shape: - return 4 - case .textureSheet: - return 3 - case .collision, .gpuSimulation, .subEmitters, .velocity: - return 2 - } -} - -private enum ParticleCurveEditorLayout { - static let cardVerticalPadding: Float = 8 - static let contentGap: Float = 7 - static let toolbarHeight: Float = 32 - static let previewHeight: Float = 72 - static let keyframeHeaderHeight: Float = 20 - static let keyframeEntryHeight: Float = 24 - static let keyframeRowGap: Float = 4 - static let propertyGridLabelHeight: Float = 18 - static let propertyGridVerticalPadding: Float = 6 - static let linearValueHeight: Float = cardVerticalPadding * 2 + toolbarHeight - static let linearRowHeight: Float = propertyGridLabelHeight - + propertyGridVerticalPadding * 2 - + linearValueHeight - - static func keyframeEntryListHeight(keyframeCount: Int) -> Float { - guard keyframeCount > 0 else { return 0 } - let rows = Float(keyframeCount) * keyframeEntryHeight - let gaps = Float(max(0, keyframeCount - 1)) * keyframeRowGap - return rows + gaps - } - - static func keyframeRowsHeight(keyframeCount: Int) -> Float { - keyframeHeaderHeight - + keyframeRowGap - + keyframeEntryListHeight(keyframeCount: keyframeCount) - } - - static func valueHeight(keyframeCount: Int) -> Float { - cardVerticalPadding * 2 - + toolbarHeight - + contentGap - + previewHeight - + contentGap - + keyframeRowsHeight(keyframeCount: keyframeCount) - } - - static func rowHeight(keyframeCount: Int) -> Float { - propertyGridLabelHeight - + propertyGridVerticalPadding * 2 - + valueHeight(keyframeCount: keyframeCount) - } -} - -private enum ParticleSubEmitterEditorLayout { - static let cardVerticalPadding: Float = 8 - static let propertyGridLabelHeight: Float = 18 - static let propertyGridVerticalPadding: Float = 6 - static let toolbarHeight: Float = 24 - static let emptyHeight: Float = 42 - static let ruleHeight: Float = 236 - static let ruleGap: Float = 8 - static let maxVisibleRules = 2 - - static func listHeight(ruleCount: Int) -> Float { - guard ruleCount > 0 else { return emptyHeight } - let visibleRules = min(ruleCount, maxVisibleRules) - let rulesHeight = Float(visibleRules) * ruleHeight - let gapsHeight = Float(max(0, visibleRules - 1)) * ruleGap - return rulesHeight + gapsHeight - } - - static func contentHeight(ruleCount: Int) -> Float { - let bodyHeight = ruleCount == 0 ? emptyHeight : listHeight(ruleCount: ruleCount) - return cardVerticalPadding * 2 - + toolbarHeight - + ruleGap - + bodyHeight - } - - static func valueHeight(ruleCount: Int) -> Float { - contentHeight(ruleCount: ruleCount) - } - - static func rowHeight(ruleCount: Int) -> Float { - propertyGridLabelHeight - + propertyGridVerticalPadding * 2 - + valueHeight(ruleCount: ruleCount) - } -} - - private struct ParticleCurveKeyframeRows: View { - let binding: Binding - let keyframes: [ParticleCurveKeyframe] - let selectedKeyIndex: Binding - let isEnabled: Bool - - var body: some View { - Box(direction: .column, alignItems: .stretch, spacing: ParticleCurveEditorLayout.keyframeRowGap) { - Row(alignment: .center, spacing: 6) { - Text("") - .frame(width: 30) - Text(L("Time")) - .font(.caption) - .foregroundColor(.onSurfaceMuted) - .frame(width: 74) - Text(L("Value")) - .font(.caption) - .foregroundColor(.onSurfaceMuted) - .frame(width: 74) - Spacer(minLength: 0) - } - .frame(height: ParticleCurveEditorLayout.keyframeHeaderHeight) - ParticleCurveKeyframeEntryList(binding: binding, - keyframes: keyframes, - selectedKeyIndex: selectedKeyIndex, - isEnabled: isEnabled) - } - .frame(height: ParticleCurveEditorLayout.keyframeRowsHeight(keyframeCount: keyframes.count)) - .padding(horizontal: 1, vertical: 0) - .clipped() - } -} - - private struct ParticleCurveKeyframeEntryList: _PrimitiveView { - let binding: Binding - let keyframes: [ParticleCurveKeyframe] - let selectedKeyIndex: Binding - let isEnabled: Bool - - func _makeNode() -> Node { - let node = Node() - node.isHitTestable = false - return node - } - - func _updateNode(_ node: Node) {} - - func _makeLayoutNode() -> LayoutNode? { - let layout = LayoutNode() - layout.flexDirection = .column - layout.alignItems = .stretch - layout.height = ParticleCurveEditorLayout.keyframeEntryListHeight(keyframeCount: keyframes.count) - layout.setGap(ParticleCurveEditorLayout.keyframeRowGap, gutter: .all) - return layout - } - - func _updateLayout(_ layout: LayoutNode) { - layout.flexDirection = .column - layout.alignItems = .stretch - layout.height = ParticleCurveEditorLayout.keyframeEntryListHeight(keyframeCount: keyframes.count) - layout.setGap(ParticleCurveEditorLayout.keyframeRowGap, gutter: .all) - } - - var _children: [any View] { - (0.. 2, - tooltip: L("Remove keyframe"), - action: { removeKeyframe(at: index) }) - .buttonStyle(.ghost) - .frame(width: 22, height: 22) - Spacer(minLength: 0) - } - .frame(height: ParticleCurveEditorLayout.keyframeEntryHeight) - ) - } - } - - private func keyTimeBinding(index: Int) -> Binding { - Binding( - get: { - guard case .keyframes(let keys) = binding.wrappedValue, - keys.indices.contains(index) - else { return 0 } - return keys[index].time - }, - set: { value in - updateKeyframe(at: index) { key in - key.time = min(max(value, 0), 1) - } - } - ) - } - - private func keyValueBinding(index: Int) -> Binding { - Binding( - get: { - guard case .keyframes(let keys) = binding.wrappedValue, - keys.indices.contains(index) - else { return 0 } - return keys[index].value - }, - set: { value in - updateKeyframe(at: index) { key in - key.value = value - } - } - ) - } - - private func updateKeyframe(at index: Int, - mutate: (inout ParticleCurveKeyframe) -> Void) { - guard isEnabled, - case .keyframes(var keys) = binding.wrappedValue, - keys.indices.contains(index) - else { return } - mutate(&keys[index]) - let editedKey = keys[index] - let sorted = keys.sortedByTimeStable() - binding.wrappedValue = .keyframes(sorted) - selectedKeyIndex.wrappedValue = sorted.nearestIndex(to: editedKey) - } - - private func removeKeyframe(at index: Int) { - guard isEnabled, keyframes.count > 2, keyframes.indices.contains(index) else { return } - var next = keyframes - next.remove(at: index) - binding.wrappedValue = .keyframes(next.sortedByTimeStable()) - selectedKeyIndex.wrappedValue = next.isEmpty ? nil : min(index, next.count - 1) - } -} - -private struct ParticleCurvePreview: View { - let binding: Binding - let selectedKeyIndex: Binding - let isEnabled: Bool - - var body: some View { - ParticleCurvePreviewHost(binding: binding, - selectedKeyIndex: selectedKeyIndex, - isEnabled: isEnabled) - .frame(minWidth: 120, minHeight: 44) - } -} - -private struct ParticleCurvePreviewHost: _PrimitiveView { - let binding: Binding - let selectedKeyIndex: Binding - let isEnabled: Bool - - private static let activeKeyIndex = "__particle_curve_active_key_index" - - func _makeNode() -> Node { - let node = Node() - node.isHitTestable = isEnabled - return node - } - - func _updateNode(_ node: Node) { - let snapshot = self - node.isHitTestable = isEnabled - node.cursor = isEnabled ? .pointer : .notAllowed - node.draw = { list, origin in - snapshot.render(node: node, origin: origin, list: list) - } - - guard let registry = InteractionRegistryHolder.current else { - InteractionRegistryHolder.current?.remove(node) - return - } - registry.setPointer(node) { event, phase, _ in - guard snapshot.isEnabled, event.button == .left else { return .ignored } - switch phase { - case .down: - PointerCaptureHolder.current?.acquire(node) - let graph = snapshot.graphRect(for: node) - let index = snapshot.pickOrInsertKey(windowX: event.x, windowY: event.y, graph: graph) - let nextIndex = snapshot.writeKey(at: index, - windowX: event.x, - windowY: event.y, - graph: graph) ?? index - node.attachments[Self.activeKeyIndex] = nextIndex - snapshot.selectedKeyIndex.wrappedValue = nextIndex - return .handled - case .up: - if let index = node.attachments[Self.activeKeyIndex] as? Int { - snapshot.selectedKeyIndex.wrappedValue = index - } - node.attachments[Self.activeKeyIndex] = nil - if PointerCaptureHolder.current?.target === node { - PointerCaptureHolder.current?.release() - } - return .handled - } - } - registry.setMotion(node) { event, _ in - guard snapshot.isEnabled, - PointerCaptureHolder.current?.target === node, - let index = node.attachments[Self.activeKeyIndex] as? Int - else { return .ignored } - if let nextIndex = snapshot.writeKey(at: index, - windowX: event.x, - windowY: event.y, - graph: snapshot.graphRect(for: node)) { - node.attachments[Self.activeKeyIndex] = nextIndex - snapshot.selectedKeyIndex.wrappedValue = nextIndex - } - return .handled - } - } - - func _makeLayoutNode() -> LayoutNode? { - let layout = LayoutNode() - layout.height = ParticleCurveEditorLayout.previewHeight - return layout - } - - func _updateLayout(_ layout: LayoutNode) { - layout.height = ParticleCurveEditorLayout.previewHeight - } - - private func render(node: Node, origin: CGPoint, list: DrawList) { - let width = Float(node.frame.width) - let height = Float(node.frame.height) - guard width > 2, height > 2 else { return } - - let curve = binding.wrappedValue - let colors = node.theme.colors - let x = Float(origin.x) - let y = Float(origin.y) - let rect = UIRect(x: x, y: y, width: width, height: height) - list.addRoundedRect(rect, radius: 5, color: colors.surfaceSunken) - drawBorder(rect: rect, color: colors.border, list: list) - - let inset: Float = 6 - let graph = UIRect(x: x + inset, - y: y + inset, - width: max(1, width - inset * 2), - height: max(1, height - inset * 2)) - - for fraction in [Float(0.25), Float(0.5), Float(0.75)] { - let gx = graph.minX + graph.width * fraction - list.addRect(UIRect(x: gx, y: graph.minY, width: 1, height: graph.height), - color: colors.divider) - let gy = graph.minY + graph.height * fraction - list.addRect(UIRect(x: graph.minX, y: gy, width: graph.width, height: 1), - color: colors.divider) - } - - let samples = 28 - var previous: (x: Float, y: Float)? - for sample in 0...samples { - let t = Float(sample) / Float(samples) - let value = curve.evaluate(at: t) - let px = graph.minX + t * graph.width - let py = graph.maxY - min(max(value, 0), 1) * graph.height - if let previous { - list.addLine(fromX: previous.x, fromY: previous.y, - toX: px, toY: py, - thickness: 2, - color: colors.accent) - } - previous = (px, py) - } - - if case .keyframes(let keyframes) = curve { - let active = node.attachments[Self.activeKeyIndex] as? Int ?? selectedKeyIndex.wrappedValue - for (index, key) in keyframes.enumerated() { - let px = graph.minX + key.time * graph.width - let py = graph.maxY - min(max(key.value, 0), 1) * graph.height - let radius: Float = active == index ? 3.5 : 2.5 - let marker = UIRect(x: px - radius, - y: py - radius, - width: radius * 2, - height: radius * 2) - list.addRoundedRect(marker, - radius: radius, - color: active == index ? colors.warning : colors.accentSecondary) - } - } - } - - private func graphRect(for node: Node) -> UIRect { - let inset: Float = 6 - let x = Float(node.absoluteOrigin.x) - let y = Float(node.absoluteOrigin.y) - let width = max(1, Float(node.frame.width) - inset * 2) - let height = max(1, Float(node.frame.height) - inset * 2) - return UIRect(x: x + inset, y: y + inset, width: width, height: height) - } - - private func pickOrInsertKey(windowX: Float, windowY: Float, graph: UIRect) -> Int { - guard case .keyframes(let keyframes) = binding.wrappedValue else { return 0 } - if let index = nearestKeyIndex(windowX: windowX, windowY: windowY, graph: graph, keyframes: keyframes) { - return index - } - - var next = keyframes - let key = keyframe(windowX: windowX, windowY: windowY, graph: graph) - next.append(key) - let sorted = next.sortedByTimeStable() - binding.wrappedValue = .keyframes(sorted) - let index = nearestKeyIndex(windowX: windowX, - windowY: windowY, - graph: graph, - keyframes: sorted) ?? max(0, sorted.count - 1) - selectedKeyIndex.wrappedValue = index - return index - } - - private func nearestKeyIndex(windowX: Float, - windowY: Float, - graph: UIRect, - keyframes: [ParticleCurveKeyframe]) -> Int? { - let thresholdSquared: Float = 64 - var best: (index: Int, distance: Float)? - for (index, key) in keyframes.enumerated() { - let px = graph.minX + key.time * graph.width - let py = graph.maxY - min(max(key.value, 0), 1) * graph.height - let dx = px - windowX - let dy = py - windowY - let distance = dx * dx + dy * dy - if distance <= thresholdSquared, best == nil || distance < best!.distance { - best = (index, distance) - } - } - return best?.index - } - - private func writeKey(at index: Int, windowX: Float, windowY: Float, graph: UIRect) -> Int? { - guard case .keyframes(var keyframes) = binding.wrappedValue, - keyframes.indices.contains(index) - else { return nil } - keyframes[index] = keyframe(windowX: windowX, windowY: windowY, graph: graph) - let sorted = keyframes.sortedByTimeStable() - binding.wrappedValue = .keyframes(sorted) - let nextIndex = nearestKeyIndex(windowX: windowX, - windowY: windowY, - graph: graph, - keyframes: sorted) - selectedKeyIndex.wrappedValue = nextIndex - return nextIndex - } - - private func keyframe(windowX: Float, windowY: Float, graph: UIRect) -> ParticleCurveKeyframe { - let time = min(max((windowX - graph.minX) / max(1, graph.width), 0), 1) - let value = min(max((graph.maxY - windowY) / max(1, graph.height), 0), 1) - return ParticleCurveKeyframe(time: time, value: value) - } - - private func drawBorder(rect: UIRect, color: Color, list: DrawList) { - list.addRect(UIRect(x: rect.minX, y: rect.minY, width: rect.width, height: 1), color: color) - list.addRect(UIRect(x: rect.minX, y: rect.maxY - 1, width: rect.width, height: 1), color: color) - list.addRect(UIRect(x: rect.minX, y: rect.minY, width: 1, height: rect.height), color: color) - list.addRect(UIRect(x: rect.maxX - 1, y: rect.minY, width: 1, height: rect.height), color: color) - } -} - -private extension Array where Element == ParticleCurveKeyframe { - func sortedByTimeStable() -> [ParticleCurveKeyframe] { - enumerated() - .sorted { - if $0.element.time == $1.element.time { - return $0.offset < $1.offset - } - return $0.element.time < $1.element.time - } - .map(\.element) - } - - func nearestIndex(to keyframe: ParticleCurveKeyframe) -> Int? { - guard !isEmpty else { return nil } - return indices.min { lhs, rhs in - let left = distanceSquared(self[lhs], keyframe) - let right = distanceSquared(self[rhs], keyframe) - return left < right - } - } - - private func distanceSquared(_ lhs: ParticleCurveKeyframe, _ rhs: ParticleCurveKeyframe) -> Float { - let dt = lhs.time - rhs.time - let dv = lhs.value - rhs.value - return dt * dt + dv * dv - } -} /// Inspector affordance that surfaces `EditorSceneAdapter.addComponent`, so any /// supported component (Particle Emitter, Rigid Body, Audio Source, …) can be diff --git a/Editor/Sources/EditorApp/Root/RootViewFactory.swift b/Editor/Sources/EditorApp/Root/RootViewFactory.swift index 24b23bfb..5fa80a85 100644 --- a/Editor/Sources/EditorApp/Root/RootViewFactory.swift +++ b/Editor/Sources/EditorApp/Root/RootViewFactory.swift @@ -155,10 +155,7 @@ enum EditorRootViewFactory { title: localizedPanelTitle(for: "inspector"), preferredSlot: .trailing, iconAssetKey: "panel.inspector") { - InspectorPanel(store: app.store, - scene: app.scene, - renderStatsProvider: { app.currentRenderStats() }, - particleEventReportProvider: { app.currentParticleSimulationEventApplyReport() }) + InspectorPanel(store: app.store, scene: app.scene) }, PanelDescriptor(id: "viewport", title: localizedPanelTitle(for: "viewport"), diff --git a/Editor/Tests/EditorAppTests/DeveloperParticleDiagnosticsTests.swift b/Editor/Tests/EditorAppTests/DeveloperParticleDiagnosticsTests.swift index 924e153d..aa88895d 100644 --- a/Editor/Tests/EditorAppTests/DeveloperParticleDiagnosticsTests.swift +++ b/Editor/Tests/EditorAppTests/DeveloperParticleDiagnosticsTests.swift @@ -505,6 +505,73 @@ struct DeveloperParticleDiagnosticsTests { #expect(makeDeveloperParticleTrendSummary(history: []) == nil) } + @Test("workbench diagnostics promote console errors ahead of frame warnings") + func workbenchDiagnosticsPrioritizeCriticalConsoleErrors() { + let issues = makeDeveloperWorkbenchIssues( + frameStats: EditorFrameStats(frameSeconds: 0.100, + simulationSeconds: 0.001), + frameHistory: [], + renderStats: .init(), + particleSummary: nil, + particleAuthoringSummary: nil, + particleHotspots: [], + selectedEntityID: nil, + consoleEntries: [ + EditorConsoleEntry(id: 1, + severity: .error, + message: "Renderer failed to create test resource"), + ] + ) + + #expect(issues.first?.scope == .console) + #expect(issues.first?.severity == .critical) + #expect(issues.contains { $0.id == "frame.pacing" }) + } + + @Test("workbench diagnostics convert particle health summaries into actionable issues") + func workbenchDiagnosticsSurfaceParticleHealth() { + let summary = DeveloperParticleDiagnosticSummary( + severity: .warning, + status: "Spawn budget throttling", + primarySignal: "12 budget-limited spawns", + recommendation: "Raise Max Spawn / Frame for bursty emitters.", + details: ["Frame budget 24/24"] + ) + + let issues = makeDeveloperWorkbenchIssues( + frameStats: EditorFrameStats(frameSeconds: 1.0 / 60.0, + simulationSeconds: 0.001), + frameHistory: [], + renderStats: .init(), + particleSummary: summary, + particleAuthoringSummary: nil, + particleHotspots: [], + selectedEntityID: nil, + consoleEntries: [] + ) + + let issue = issues.first { $0.scope == .particles } + #expect(issue?.severity == .warning) + #expect(issue?.title == "Spawn budget throttling") + #expect(issue?.target.tab == .particles) + #expect(issue?.recommendation.contains("Max Spawn / Frame") == true) + } + + @Test("render pass breakdown ranks the expensive pass first") + func renderPassBreakdownRanksExpensivePassesFirst() { + let renderStats = RenderFrameStats( + passDrawCallCounts: [.basePass: 12, .particles: 3], + activePasses: [.basePass, .particles], + passEncodeNS: [.basePass: 1_000_000, .particles: 17_000_000] + ) + + let passes = makeDeveloperRenderPassBreakdown(renderStats: renderStats) + + #expect(passes.first?.name == RenderPassKind.particles.rawValue) + #expect(passes.first?.encodeNS == 17_000_000) + #expect(passes.first?.recommendation.contains("Inspect") == true) + } + private func particleSample(index: UInt64, live: Int, limit: Int, diff --git a/GuavaUI/Sources/GuavaUICompose/Primitives/BoundedScrollView.swift b/GuavaUI/Sources/GuavaUICompose/Primitives/BoundedScrollView.swift new file mode 100644 index 00000000..b80c8bfe --- /dev/null +++ b/GuavaUI/Sources/GuavaUICompose/Primitives/BoundedScrollView.swift @@ -0,0 +1,48 @@ +import GuavaUIRuntime + +public struct BoundedScrollView: View { + public let axes: ScrollView.Axis + public let contentHeight: Float + public let minHeight: Float + public let maxHeight: Float + public let consumePolicy: ScrollConsumePolicy + public let scrollbarGutter: ScrollView.ScrollbarGutter + public let content: AnyView + + public init(_ axes: ScrollView.Axis = .vertical, + contentHeight: Float, + minHeight: Float, + maxHeight: Float, + consumePolicy: ScrollConsumePolicy = .whenOffsetChanged, + scrollbarGutter: ScrollView.ScrollbarGutter = .stable, + @ViewBuilder content: () -> Content) { + self.axes = axes + self.contentHeight = contentHeight + self.minHeight = minHeight + self.maxHeight = maxHeight + self.consumePolicy = consumePolicy + self.scrollbarGutter = scrollbarGutter + self.content = AnyView(content()) + } + + public var viewportHeight: Float { + Self.viewportHeight(contentHeight: contentHeight, + minHeight: minHeight, + maxHeight: maxHeight) + } + + public var body: some View { + ScrollView(axes, + consumePolicy: consumePolicy, + scrollbarGutter: scrollbarGutter) { + AnyView(content.frame(height: contentHeight)) + } + .frame(height: viewportHeight) + } + + public static func viewportHeight(contentHeight: Float, + minHeight: Float, + maxHeight: Float) -> Float { + min(max(contentHeight, minHeight), maxHeight) + } +} diff --git a/GuavaUI/Sources/GuavaUICompose/Primitives/PropertyGrid.swift b/GuavaUI/Sources/GuavaUICompose/Primitives/PropertyGrid.swift index 8e8cfa0c..f115f7b7 100644 --- a/GuavaUI/Sources/GuavaUICompose/Primitives/PropertyGrid.swift +++ b/GuavaUI/Sources/GuavaUICompose/Primitives/PropertyGrid.swift @@ -254,8 +254,9 @@ private struct _StatefulPropertyGrid: View { private func fullWidthRowView(_ row: PropertyGridRow, rowHeight: Float) -> some View { let labelHeight: Float = 18 let verticalPadding: Float = 6 - let valueHeight = max(grid.rowHeight, rowHeight - labelHeight - verticalPadding * 2) - return Box(direction: .column, alignItems: .stretch, spacing: 6) { + let labelValueSpacing: Float = 6 + let valueHeight = max(grid.rowHeight, rowHeight - labelHeight - labelValueSpacing - verticalPadding * 2) + return Box(direction: .column, alignItems: .stretch, spacing: labelValueSpacing) { Text(row.label) .lineLimit(1) .font(.caption)