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..c8c3dd9 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'; @@ -406,6 +407,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 +1447,28 @@ export class PlannerComponent implements AfterViewInit, OnDestroy, HotkeyItemSou this.planManager.setInputs(plan.id, plan.inputs.filter(input => input.itemClassName !== itemClassName)); } + /** 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(); + 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: 0}]); + } + /** Leaves the dirty flag as is, like manual node drags. */ private async relayoutGraph(): Promise { 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 => {