From aef55a3c8940562bece5a005eca0ba96602f96f9 Mon Sep 17 00:00:00 2001 From: Samuel Recker Date: Sat, 17 Oct 2026 10:54:25 +0200 Subject: [PATCH 1/2] feat: add 'Add to inputs' to the node context menu --- .../Planner/ContextMenu/NodeContextMenu.ts | 7 ++++- .../Planner/PlannerActionsService.ts | 9 ++++++ .../Components/Planner/PlannerComponent.ts | 29 +++++++++++++++++++ 3 files changed, 44 insertions(+), 1 deletion(-) diff --git a/src/app/Components/Planner/ContextMenu/NodeContextMenu.ts b/src/app/Components/Planner/ContextMenu/NodeContextMenu.ts index 9cd6ada..3b39d2e 100644 --- a/src/app/Components/Planner/ContextMenu/NodeContextMenu.ts +++ b/src/app/Components/Planner/ContextMenu/NodeContextMenu.ts @@ -1,4 +1,4 @@ -import {faArrowsSplitUpAndLeft, faBan, faCheck, faCompress, faCrosshairs, faDiagramProject, faExpand, faFolderOpen, faLock, faLockOpen, faRotateLeft, faTrashCan, faXmark} from '@fortawesome/free-solid-svg-icons'; +import {faArrowsSplitUpAndLeft, faBan, faCheck, faCompress, faCrosshairs, faDiagramProject, faExpand, faFolderOpen, faLock, faLockOpen, faRightToBracket, faRotateLeft, faTrashCan, faXmark} from '@fortawesome/free-solid-svg-icons'; import {ContextMenuItem} from '@src/Components/Planner/ContextMenu/ContextMenuItem'; import {NodeResizeOptions} from '@src/Components/Planner/ContextMenu/NodeResizeOptions'; import {NodeSplitOptions} from '@src/Components/Planner/ContextMenu/NodeSplitOptions'; @@ -130,6 +130,11 @@ export class NodeContextMenu extends PlannerContextMenu hotkey: 'graph.disableMachine', action: () => this.actions.requestMachineDisable(node.machine.className), }, + { + label: `Add to inputs (${node.recipe.products[0]?.item.name ?? 'main product'})`, + icon: faRightToBracket, + action: () => this.actions.requestInputAdd(node), + }, ]; } diff --git a/src/app/Components/Planner/PlannerActionsService.ts b/src/app/Components/Planner/PlannerActionsService.ts index c0af6ca..3838499 100644 --- a/src/app/Components/Planner/PlannerActionsService.ts +++ b/src/app/Components/Planner/PlannerActionsService.ts @@ -12,6 +12,7 @@ import {SubplanScaleRequest} from '@src/Components/Planner/SubplanScaleRequest'; import {GraphEdge} from '@src/Model/Planner/Graph/GraphEdge'; import {GraphPoint} from '@src/Model/Planner/Graph/GraphPoint'; import {Node} from '@src/Model/Planner/Solver/Response/Node'; +import {RecipeNode} from '@src/Model/Planner/Solver/Response/RecipeNode'; /** Panels are created via ngComponentOutlet and cannot use output bindings, so their actions reach PlannerComponent through here. */ @Injectable() @@ -96,6 +97,9 @@ export class PlannerActionsService private readonly inputRemoveSubject = new Subject(); public readonly inputRemoveRequests: Observable = this.inputRemoveSubject.asObservable(); + private readonly inputAddSubject = new Subject(); + public readonly inputAddRequests: Observable = this.inputAddSubject.asObservable(); + private readonly undoSubject = new Subject(); public readonly undoRequests: Observable = this.undoSubject.asObservable(); @@ -241,6 +245,11 @@ export class PlannerActionsService this.inputRemoveSubject.next(itemClassName); } + public requestInputAdd(node: RecipeNode): void + { + this.inputAddSubject.next(node); + } + public requestUndo(): void { this.undoSubject.next(); diff --git a/src/app/Components/Planner/PlannerComponent.ts b/src/app/Components/Planner/PlannerComponent.ts index 9f02922..90faae9 100644 --- a/src/app/Components/Planner/PlannerComponent.ts +++ b/src/app/Components/Planner/PlannerComponent.ts @@ -77,6 +77,7 @@ import {ByproductNode} from '@src/Model/Planner/Solver/Response/ByproductNode'; import {InputNode} from '@src/Model/Planner/Solver/Response/InputNode'; import {Node} from '@src/Model/Planner/Solver/Response/Node'; import {ProductNode} from '@src/Model/Planner/Solver/Response/ProductNode'; +import {RecipeNode} from '@src/Model/Planner/Solver/Response/RecipeNode'; import {SolverResponse} from '@src/Model/Planner/Solver/Response/SolverResponse'; import {SubplanNode} from '@src/Model/Planner/Solver/Response/SubplanNode'; import {SubplanBuildCountRequest} from '@src/Components/Planner/SubplanBuildCountRequest'; @@ -106,6 +107,8 @@ import {GraphExportDialogService} from '@src/Components/Planner/Export/GraphExpo // Matches the reconciler's absolute warning tolerance. const FLOW_TOLERANCE = 0.001; const RATIO_TOLERANCE = 1e-4; +// Below every resource, power and machine weight, so the solver prefers the input to making the item - but not free, which would let it pull unused inputs into byproducts. +const ADDED_INPUT_WEIGHT = 0.001; @Component({ templateUrl: './PlannerComponent.html', @@ -406,6 +409,10 @@ export class PlannerComponent implements AfterViewInit, OnDestroy, HotkeyItemSou this.actions.inputRemoveRequests.subscribe(itemClassName => this.removeInput(itemClassName)), ); + this.subscription.add( + this.actions.inputAddRequests.subscribe(node => this.addInput(node)), + ); + this.subscription.add( toObservable(computed(() => { const planGraph = this.planManager.activePlan()?.settings.graph; @@ -1442,6 +1449,28 @@ export class PlannerComponent implements AfterViewInit, OnDestroy, HotkeyItemSou this.planManager.setInputs(plan.id, plan.inputs.filter(input => input.itemClassName !== itemClassName)); } + /** The node is unlocked, or the next solve would keep it next to the input. */ + private addInput(node: RecipeNode): void + { + const plan = this.planManager.activePlan(); + const item = node.recipe.products[0]?.item; + if (!plan || !item) { + return; + } + const output = node.outputs + .filter(io => io.item.className === item.className) + .reduce((sum, io) => sum + io.maxAmount, 0); + // Rounded up, so solver noise (9.999999999375) reads cleanly in the Input tab and never leaves the input a sliver short. + const amount = Math.ceil(output * 10000 - 1e-6) / 10000; + if (node.locked) { + this.applyLockChange({nodeIds: [node.id], locked: false}); + } + const existing = plan.inputs.find(input => input.itemClassName === item.className); + this.planManager.setInputs(plan.id, existing + ? plan.inputs.map(input => input === existing ? {...input, amount: input.amount + amount} : input) + : [...plan.inputs, {itemClassName: item.className, amount, weight: ADDED_INPUT_WEIGHT}]); + } + /** Leaves the dirty flag as is, like manual node drags. */ private async relayoutGraph(): Promise { From 240a75c6bb49605003ebe7a14ded9fcb0a4dc461 Mon Sep 17 00:00:00 2001 From: Samuel Recker Date: Sat, 17 Oct 2026 11:20:01 +0200 Subject: [PATCH 2/2] fix: never draw unneeded inputs in the solver Inputs with weight 0, and all inputs when input weights are turned off, were left out of the objective. Byproducts cost nothing, so once an input exceeded the demand the solver could draw it and dump it into a byproduct at no cost. Every input now carries a 1e-6 tie-break cost on top of its weight. That's far below any real weight (water: 0.01) and far above the 1e-9 dual tolerance, so it only breaks ties. 'Add to inputs' goes back to weight 0 now that free inputs behave. --- src/app/Components/Planner/PlannerComponent.ts | 6 ++---- src/app/Model/Planner/ProductionSolverService.ts | 15 ++++++++------- 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/src/app/Components/Planner/PlannerComponent.ts b/src/app/Components/Planner/PlannerComponent.ts index 90faae9..c8c3dd9 100644 --- a/src/app/Components/Planner/PlannerComponent.ts +++ b/src/app/Components/Planner/PlannerComponent.ts @@ -107,8 +107,6 @@ import {GraphExportDialogService} from '@src/Components/Planner/Export/GraphExpo // Matches the reconciler's absolute warning tolerance. const FLOW_TOLERANCE = 0.001; const RATIO_TOLERANCE = 1e-4; -// Below every resource, power and machine weight, so the solver prefers the input to making the item - but not free, which would let it pull unused inputs into byproducts. -const ADDED_INPUT_WEIGHT = 0.001; @Component({ templateUrl: './PlannerComponent.html', @@ -1449,7 +1447,7 @@ export class PlannerComponent implements AfterViewInit, OnDestroy, HotkeyItemSou this.planManager.setInputs(plan.id, plan.inputs.filter(input => input.itemClassName !== itemClassName)); } - /** The node is unlocked, or the next solve would keep it next to the input. */ + /** Free, so the solver uses the input instead of making the item. The node is unlocked, or the next solve would keep it next to the input. */ private addInput(node: RecipeNode): void { const plan = this.planManager.activePlan(); @@ -1468,7 +1466,7 @@ export class PlannerComponent implements AfterViewInit, OnDestroy, HotkeyItemSou const existing = plan.inputs.find(input => input.itemClassName === item.className); this.planManager.setInputs(plan.id, existing ? plan.inputs.map(input => input === existing ? {...input, amount: input.amount + amount} : input) - : [...plan.inputs, {itemClassName: item.className, amount, weight: ADDED_INPUT_WEIGHT}]); + : [...plan.inputs, {itemClassName: item.className, amount, weight: 0}]); } /** Leaves the dirty flag as is, like manual node drags. */ diff --git a/src/app/Model/Planner/ProductionSolverService.ts b/src/app/Model/Planner/ProductionSolverService.ts index 78f6bb5..386613e 100644 --- a/src/app/Model/Planner/ProductionSolverService.ts +++ b/src/app/Model/Planner/ProductionSolverService.ts @@ -49,6 +49,9 @@ export class ProductionSolverService private static readonly MAXIMISE_EPSILON = 0.001; + /** Far below any real weight (water: 0.01), far above the 1e-9 dual tolerance - it only breaks ties. */ + private static readonly INPUT_TIE_BREAK = 1e-6; + public constructor( private readonly solver: SolverService, private readonly versionManager: VersionManager, @@ -489,13 +492,11 @@ export class ProductionSolverService } }) - if (request.optimisation.inputs) { - request.inputs.forEach(input => { - if (input.weight > 0) { - optimisation.push(input.weight + ' ' + input.item.className + '@Input'); - } - }); - } + // Never free, even at weight 0: byproducts cost nothing, so a free input the plan does not need could be drawn and dumped into one. + request.inputs.forEach(input => { + const weight = request.optimisation.inputs ? input.weight : 0; + optimisation.push((weight + ProductionSolverService.INPUT_TIE_BREAK) + ' ' + input.item.className + '@Input'); + }); if (request.optimisation.power > 0 || request.optimisation.machines > 0) { request.recipes.forEach(recipe => {