From 75cc60fd42c58cadc28f5eb4499f197604254aba Mon Sep 17 00:00:00 2001 From: "bozo.kopic" Date: Sun, 26 Jun 2022 17:28:49 +0200 Subject: WIP native implementation --- CONTRIBUTING | 1 + README.rst | 84 ++++++++++++---- VERSION | 2 +- peru.yaml | 12 --- requirements.pip.runtime.txt | 6 +- schemas/opcut.yaml | 54 ++++++++++ schemas/openapi.yaml | 13 +-- src_js/common.js | 9 +- src_js/states.js | 1 - src_js/vt.js | 13 +-- src_py/opcut/calculate.py | 175 +++++++++++++++++++++++++++++++++ src_py/opcut/common.py | 4 +- src_py/opcut/csp.py | 161 ------------------------------ src_py/opcut/generate.py | 117 ++++++++++++++++++++++ src_py/opcut/main.py | 229 ++++++++++++++++++++++++++----------------- src_py/opcut/output.py | 117 ---------------------- src_py/opcut/server.py | 44 +++------ 17 files changed, 581 insertions(+), 461 deletions(-) create mode 100644 CONTRIBUTING create mode 100644 src_py/opcut/calculate.py delete mode 100644 src_py/opcut/csp.py create mode 100644 src_py/opcut/generate.py delete mode 100644 src_py/opcut/output.py diff --git a/CONTRIBUTING b/CONTRIBUTING new file mode 100644 index 0000000..1333ed7 --- /dev/null +++ b/CONTRIBUTING @@ -0,0 +1 @@ +TODO diff --git a/README.rst b/README.rst index 19c3161..8e893a7 100644 --- a/README.rst +++ b/README.rst @@ -37,26 +37,70 @@ Install `https://github.com/bozokopic/opcut/releases` -Run ---- +Usage +----- -Running server (default listening address http://0.0.0.0:8080):: +`opcut` command is interface for execution of three distinct actions: - $ opcut server + * `opcut calculate ...` + + Calculation of cutting stock problem. Input parameters and result is + formated as JSON data (JSON, YAML or TOML). + + * `opcut generate ...` + + Generate output representation (SVG, PDF, ...) based on calculation + result. + + * `opcut server ...` + + Run HTTP server providing single-page web application interface and + OpenAPI interface. + (default listening address is http://0.0.0.0:8080). + +For additional command line arguments, run ``opcut --help`` or +``opcut --help``. + + +`opcut calculate` +''''''''''''''''' + +Example:: -Running command line utility:: + $ opcut calculate --input-format yaml --output result.json << EOF + cut_width: 1 + panels: + panel1: + width: 100 + height: 100 + items: + item1: + width: 10 + height: 10 + can_rotate: false + EOF - $ opcut calculate ... - $ opcut generate_output ... -Additional command line arguments:: +`opcut generate` +'''''''''''''''' - $ opcut --help +Example:: + + $ opcut generate --output output.pdf result.json + + +`opcut server` +'''''''''''''' + +Example:: + + $ opcut server Development requirements ------------------------ +* C99 compiler (gcc, clang, ...) * nodejs >=7 * yarn @@ -80,28 +124,26 @@ Default task:: creates wheel package inside `build` directory. -TODO ----- - -* global - - * create CONTRIBUTING +JSON Schema +----------- -* optimizer +.. literalinclude:: schemas/opcut.yaml + :language: yaml - * add additional algorithms - * evaluate python implementations and do native rewrites if needed -* back-end +OpenAPI +------- - * additional output formats +.. literalinclude:: schemas/openapi.yaml + :language: yaml License ------- opcut - cutting stock problem optimizer -Copyright (C) 2017-2022 Bozo Kopic + +Copyright (C) 2017-2022 Bozo Kopic This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by diff --git a/VERSION b/VERSION index d15723f..25a5afd 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.3.2 +0.4.0-dev diff --git a/peru.yaml b/peru.yaml index 991025f..b28cdc3 100644 --- a/peru.yaml +++ b/peru.yaml @@ -1,18 +1,6 @@ imports: - jsmn: deps/jsmn - argparse: deps/argparse hat-util: deps/hat-util -git module jsmn: - url: https://github.com/zserge/jsmn - pick: jsmn.h - -git module argparse: - url: https://github.com/cofyc/argparse - pick: - - argparse.c - - argparse.h - git module hat-util: url: https://github.com/hat-open/hat-util rev: 7f56bb0572aee5a33654118838f999eec04fbd86 diff --git a/requirements.pip.runtime.txt b/requirements.pip.runtime.txt index 4499a99..db3c695 100644 --- a/requirements.pip.runtime.txt +++ b/requirements.pip.runtime.txt @@ -1,6 +1,4 @@ aiohttp ~= 3.8.1 -appdirs ~= 1.4.4 -click ~= 8.1.3 -hat-aio ~= 0.6.0 -hat-json ~= 0.5.5 +hat-aio ~= 0.6.1 +hat-json ~= 0.5.6 pycairo ~= 1.21.0 diff --git a/schemas/opcut.yaml b/schemas/opcut.yaml index 47e0a7f..8dadb93 100644 --- a/schemas/opcut.yaml +++ b/schemas/opcut.yaml @@ -4,6 +4,8 @@ id: "opcut://opcut.yaml#" definitions: params: type: object + description: | + calculation input parameters independent of calculation method required: - cut_width - panels @@ -11,20 +13,30 @@ definitions: properties: cut_width: type: number + description: | + width of the guillotine cut min_initial_usage: type: boolean + description: | + minimize usage of initial panels panels: type: object + description: | + input panels (keys represent unique panel identifiers) patternProperties: ".+": "$ref": "opcut://opcut.yaml#/definitions/panel" items: type: object + description: | + required items (keys represent unique item identifiers) patternProperties: ".+": "$ref": "opcut://opcut.yaml#/definitions/item" result: type: object + description: | + calculation result required: - params - used @@ -34,24 +46,36 @@ definitions: "$ref": "opcut://opcut.yaml#" used: type: array + description: | + resulting panels associated with required items items: "$ref": "opcut://opcut.yaml#/definitions/used" unused: type: array + description: | + resulting unused panels items: "$ref": "opcut://opcut.yaml#/definitions/unused" panel: type: object + description: | + single input panel required: - width - height properties: width: type: number + description: | + panel's initial width height: type: number + description: | + panel's initial height item: type: object + description: | + single required item required: - width - height @@ -59,12 +83,20 @@ definitions: properties: width: type: number + description: | + items's width height: type: number + description: | + items's height can_rotate: type: boolean + description: | + can item be rotated (is grain direction irrelevant) used: type: object + description: | + single resulting panels associated with required item required: - panel - item @@ -74,16 +106,28 @@ definitions: properties: panel: type: string + description: | + input panel identifier item: type: string + description: | + matching required item identifier x: type: number + description: | + used panel location based on input panel's width offset y: type: number + description: | + used panel location based on input panel's height offset rotate: type: boolean + description: | + is resulting panel rotated unused: type: object + description: | + single unused resulting panel required: - panel - width @@ -93,12 +137,22 @@ definitions: properties: panel: type: string + description: | + input panel identifier width: type: number + description: | + unused resulting panel's width height: type: number + description: | + unused resulting panel's height x: type: number + description: | + used panel location based on input panel's width offset y: type: number + description: | + used panel location based on input panel's height offset ... diff --git a/schemas/openapi.yaml b/schemas/openapi.yaml index 442f685..e459030 100644 --- a/schemas/openapi.yaml +++ b/schemas/openapi.yaml @@ -2,16 +2,11 @@ openapi: 3.1.0 info: title: opcut - version: 0.0.1 + version: 0.0.2 paths: '/calculate': post: parameters: - - name: native - in: query - required: false - schema: - type: boolean - name: method in: query required: true @@ -19,6 +14,8 @@ paths: enum: - greedy - forward_greedy + - greedy_native + - forward_greedy_native requestBody: content: application/json: @@ -34,10 +31,10 @@ paths: content: text/plain: description: error message - '/generate_output': + '/generate': post: parameters: - - name: output_type + - name: output_format in: query required: true schema: diff --git a/src_js/common.js b/src_js/common.js index 7d4213c..b489914 100644 --- a/src_js/common.js +++ b/src_js/common.js @@ -10,7 +10,7 @@ import * as states from './states'; const calculateUrl = URI.resolve(window.location.href, './calculate'); -const generateOutputUrl = URI.resolve(window.location.href, './generate_output'); +const generateUrl = URI.resolve(window.location.href, './generate'); let panelCounter = 0; let itemCounter = 0; @@ -20,9 +20,8 @@ export async function calculate() { r.set('calculating', true); try { const method = r.get('form', 'method'); - const native = r.get('form', 'native'); const params = createCalculateParams(); - const res = await fetch(`${calculateUrl}?method=${method}&native=${native}`, { + const res = await fetch(`${calculateUrl}?method=${method}`, { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(params) @@ -45,10 +44,10 @@ export async function calculate() { } -export async function generateOutput() { +export async function generate() { try { const result = r.get('result'); - const res = await fetch(`${generateOutputUrl}?output_type=pdf`, { + const res = await fetch(`${generateUrl}?output_format=pdf`, { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(result) diff --git a/src_js/states.js b/src_js/states.js index efc31b9..bf3584e 100644 --- a/src_js/states.js +++ b/src_js/states.js @@ -4,7 +4,6 @@ export const main = { method: 'forward_greedy', cut_width: 0.3, min_initial_usage: false, - native: false, panels: [], items: [] }, diff --git a/src_js/vt.js b/src_js/vt.js index b016018..84d80a0 100644 --- a/src_js/vt.js +++ b/src_js/vt.js @@ -62,7 +62,9 @@ function leftPanel() { ['label', 'Method'], selectInput(r.get('form', 'method'), [['forward_greedy', 'Forward greedy'], - ['greedy', 'Greedy']], + ['greedy', 'Greedy'], + ['forward_greedy_native', 'Forward greedy (native)'], + ['greedy_native', 'Greedy (native)'],], val => r.set(['form', 'method'], val)), ['label', 'Cut width'], numberInput(r.get('form', 'cut_width'), @@ -71,11 +73,7 @@ function leftPanel() { ['label'], checkboxInput('Minimize initial panel usage', r.get('form', 'min_initial_usage'), - val => r.set(['form', 'min_initial_usage'], val)), - ['label'], - checkboxInput('Use native implementation (experimental)', - r.get('form', 'native'), - val => r.set(['form', 'native'], val)) + val => r.set(['form', 'min_initial_usage'], val)) ], ['div.content', leftPanelPanels(), @@ -308,7 +306,7 @@ function rightPanel() { ['div.toolbar', ['button', { on: { - click: common.generateOutput + click: common.generate }}, ['span.fa.fa-file-pdf-o'], ' PDF' @@ -465,7 +463,6 @@ function textInput(value, validator, onChange) { function numberInput(value, validator, onChange) { - return ['input', { props: { type: 'number', diff --git a/src_py/opcut/calculate.py b/src_py/opcut/calculate.py new file mode 100644 index 0000000..7ae14ce --- /dev/null +++ b/src_py/opcut/calculate.py @@ -0,0 +1,175 @@ +import itertools + +from opcut import common + + +def calculate(method: common.Method, + params: common.Params + ) -> common.Result: + """Calculate cutting stock problem""" + unused = [common.Unused(panel=panel, + width=panel.width, + height=panel.height, + x=0, + y=0) + for panel in params.panels] + result = common.Result(params=params, + used=[], + unused=unused) + + if method == common.Method.GREEDY: + return _calculate_greedy(result) + + elif method == common.Method.FORWARD_GREEDY: + return _calculate_forward_greedy(result) + + elif method == common.Method.GREEDY_NATIVE: + return _calculate_greedy_native(result) + + elif method == common.Method.FORWARD_GREEDY_NATIVE: + return _calculate_forward_greedy_native(result) + + raise ValueError('unsupported method') + + +_fitness_K = 0.03 + + +def _calculate_greedy(result): + while not _is_done(result): + new_result = None + new_fitness = None + for next_result in _get_next_results(result): + next_result_fitness = _fitness(next_result) + if new_fitness is None or next_result_fitness < new_fitness: + new_result = next_result + new_fitness = next_result_fitness + if not new_result: + raise common.UnresolvableError() + result = new_result + return result + + +def _calculate_forward_greedy(result): + while not _is_done(result): + new_result = None + new_fitness = None + for next_result in _get_next_results(result): + try: + next_result_fitness = _fitness(_calculate_greedy(next_result)) + except common.UnresolvableError: + continue + if new_fitness is None or next_result_fitness < new_fitness: + new_result = next_result + new_fitness = next_result_fitness + if not new_result: + raise common.UnresolvableError() + result = new_result + return result + + +def _calculate_greedy_native(result): + raise NotImplementedError() + + +def _calculate_forward_greedy_native(result): + raise NotImplementedError() + + +def _get_next_results(result): + selected_item = None + used_items = {used.item.id for used in result.used} + for item in result.params.items: + if item.id in used_items: + continue + if (not selected_item or + max(item.width, item.height) > + max(selected_item.width, selected_item.height)): + selected_item = item + if not selected_item: + raise Exception('result is done') + return _get_next_results_for_item(result, selected_item) + + +def _get_next_results_for_item(result, item): + ret = [] + loop_iter = ((False, i, unused) for i, unused in enumerate(result.unused)) + if item.can_rotate: + loop_iter = itertools.chain( + loop_iter, + ((True, i, unused) for i, unused in enumerate(result.unused))) + for rotate, i, unused in loop_iter: + for vertical in [True, False]: + new_used, new_unused = _cut_item_from_unused( + unused, item, rotate, result.params.cut_width, vertical) + if not new_used: + continue + ret.append(result._replace( + used=result.used + [new_used], + unused=result.unused[:i] + new_unused + result.unused[i+1:])) + return ret + + +def _cut_item_from_unused(unused, item, rotate, cut_width, vertical): + item_width = item.width if not rotate else item.height + item_height = item.height if not rotate else item.width + if unused.height < item_height or unused.width < item_width: + return None, [] + used = common.Used(panel=unused.panel, + item=item, + x=unused.x, + y=unused.y, + rotate=rotate) + new_unused = [] + width = unused.width - item_width - cut_width + height = unused.height if vertical else item_height + if width > 0: + new_unused.append(common.Unused(panel=unused.panel, + width=width, + height=height, + x=unused.x + item_width + cut_width, + y=unused.y)) + width = item_width if vertical else unused.width + height = unused.height - item_height - cut_width + if height > 0: + new_unused.append(common.Unused(panel=unused.panel, + width=width, + height=height, + x=unused.x, + y=unused.y + item_height + cut_width)) + return used, new_unused + + +def _is_done(result): + return len(result.params.items) == len(result.used) + + +def _fitness(result): + total_area = sum(panel.width * panel.height + for panel in result.params.panels) + fitness = 0 + for panel in result.params.panels: + used_areas = [used.item.width * used.item.height + for used in result.used + if used.panel == panel] + unused_areas = [unused.width * unused.height + for unused in result.unused + if unused.panel == panel] + fitness += (panel.width * panel.height - sum(used_areas)) / total_area + fitness -= (_fitness_K * + min(used_areas, default=0) * max(unused_areas, default=0) / + (total_area * total_area)) + + if not result.params.min_initial_usage: + return fitness + + unused_initial_count = sum(1 for unused in result.unused + if _is_unused_initial(unused)) + return (-unused_initial_count, fitness) + + +def _is_unused_initial(unused): + return (unused.x == 0 and + unused.y == 0 and + unused.width == unused.panel.width and + unused.height == unused.panel.height) diff --git a/src_py/opcut/common.py b/src_py/opcut/common.py index 9fbb7dd..5f6f26b 100644 --- a/src_py/opcut/common.py +++ b/src_py/opcut/common.py @@ -67,9 +67,11 @@ class OutputSettings(typing.NamedTuple): class Method(enum.Enum): GREEDY = 'greedy' FORWARD_GREEDY = 'forward_greedy' + GREEDY_NATIVE = 'greedy_native' + FORWARD_GREEDY_NATIVE = 'forward_greedy_native' -class OutputType(enum.Enum): +class OutputFormat(enum.Enum): PDF = 'pdf' SVG = 'svg' diff --git a/src_py/opcut/csp.py b/src_py/opcut/csp.py deleted file mode 100644 index eb08314..0000000 --- a/src_py/opcut/csp.py +++ /dev/null @@ -1,161 +0,0 @@ -import itertools - -from opcut import common - - -def calculate(params: common.Params, - method: common.Method - ) -> common.Result: - """Calculate cutting stock problem""" - unused = [common.Unused(panel=panel, - width=panel.width, - height=panel.height, - x=0, - y=0) - for panel in params.panels] - result = common.Result(params=params, - used=[], - unused=unused) - - if method == common.Method.GREEDY: - return _calculate_greedy(result) - - elif method == common.Method.FORWARD_GREEDY: - return _calculate_forward_greedy(result) - - raise ValueError('unsupported method') - - -_fitness_K = 0.03 - - -def _calculate_greedy(result): - while not _is_done(result): - new_result = None - new_fitness = None - for next_result in _get_next_results(result): - next_result_fitness = _fitness(next_result) - if new_fitness is None or next_result_fitness < new_fitness: - new_result = next_result - new_fitness = next_result_fitness - if not new_result: - raise common.UnresolvableError() - result = new_result - return result - - -def _calculate_forward_greedy(result): - while not _is_done(result): - new_result = None - new_fitness = None - for next_result in _get_next_results(result): - try: - next_result_fitness = _fitness(_calculate_greedy(next_result)) - except common.UnresolvableError: - continue - if new_fitness is None or next_result_fitness < new_fitness: - new_result = next_result - new_fitness = next_result_fitness - if not new_result: - raise common.UnresolvableError() - result = new_result - return result - - -def _get_next_results(result): - selected_item = None - used_items = {used.item.id for used in result.used} - for item in result.params.items: - if item.id in used_items: - continue - if (not selected_item or - max(item.width, item.height) > - max(selected_item.width, selected_item.height)): - selected_item = item - if not selected_item: - raise Exception('result is done') - return _get_next_results_for_item(result, selected_item) - - -def _get_next_results_for_item(result, item): - ret = [] - loop_iter = ((False, i, unused) for i, unused in enumerate(result.unused)) - if item.can_rotate: - loop_iter = itertools.chain( - loop_iter, - ((True, i, unused) for i, unused in enumerate(result.unused))) - for rotate, i, unused in loop_iter: - for vertical in [True, False]: - new_used, new_unused = _cut_item_from_unused( - unused, item, rotate, result.params.cut_width, vertical) - if not new_used: - continue - ret.append(result._replace( - used=result.used + [new_used], - unused=result.unused[:i] + new_unused + result.unused[i+1:])) - return ret - - -def _cut_item_from_unused(unused, item, rotate, cut_width, vertical): - item_width = item.width if not rotate else item.height - item_height = item.height if not rotate else item.width - if unused.height < item_height or unused.width < item_width: - return None, [] - used = common.Used(panel=unused.panel, - item=item, - x=unused.x, - y=unused.y, - rotate=rotate) - new_unused = [] - width = unused.width - item_width - cut_width - height = unused.height if vertical else item_height - if width > 0: - new_unused.append(common.Unused(panel=unused.panel, - width=width, - height=height, - x=unused.x + item_width + cut_width, - y=unused.y)) - width = item_width if vertical else unused.width - height = unused.height - item_height - cut_width - if height > 0: - new_unused.append(common.Unused(panel=unused.panel, - width=width, - height=height, - x=unused.x, - y=unused.y + item_height + cut_width)) - return used, new_unused - - -def _is_done(result): - return len(result.params.items) == len(result.used) - - -def _fitness(result): - total_area = sum(panel.width * panel.height - for panel in result.params.panels) - fitness = 0 - for panel in result.params.panels: - used_areas = [used.item.width * used.item.height - for used in result.used - if used.panel == panel] - unused_areas = [unused.width * unused.height - for unused in result.unused - if unused.panel == panel] - fitness += (panel.width * panel.height - sum(used_areas)) / total_area - fitness -= (_fitness_K * - min(used_areas, default=0) * max(unused_areas, default=0) / - (total_area * total_area)) - - if not result.params.min_initial_usage: - return fitness - - unused_initial_count = sum(1 for unused in result.unused - if _is_unused_initial(unused)) - return (-unused_initial_count, fitness) - - -def _is_unused_initial(unused): - return (unused.x == 0 and - unused.y == 0 and - unused.width == unused.panel.width and - unused.height == unused.panel.height) diff --git a/src_py/opcut/generate.py b/src_py/opcut/generate.py new file mode 100644 index 0000000..82c8d7b --- /dev/null +++ b/src_py/opcut/generate.py @@ -0,0 +1,117 @@ +import io +import typing + +import cairo + +from opcut import common + + +def generate(result: common.Result, + output_format: common.OutputFormat, + panel_id: typing.Optional[str] = None, + settings: common.OutputSettings = common.OutputSettings() + ) -> bytes: + """Generate output""" + ret = io.BytesIO() + + if output_format == common.OutputFormat.PDF: + surface_cls = cairo.PDFSurface + + elif output_format == common.OutputFormat.SVG: + surface_cls = cairo.SVGSurface + + else: + raise ValueError('unsupported output type') + + with surface_cls(ret, + settings.pagesize[0], + settings.pagesize[1]) as surface: + for panel in result.params.panels: + if panel_id and panel.id != panel_id: + continue + + _write_panel(surface, settings, result, panel) + surface.show_page() + + return ret.getvalue() + + +def _write_panel(surface, settings, result, panel): + scale = _calculate_scale(settings, panel) + width = panel.width * scale + height = panel.height * scale + x0 = ((settings.pagesize[0] - width) * settings.margin_left / + (settings.margin_left + settings.margin_right)) + y0 = ((settings.pagesize[1] - height) * settings.margin_top / + (settings.margin_top + settings.margin_bottom)) + + ctx = cairo.Context(surface) + ctx.set_line_width(0) + ctx.set_source_rgb(0.5, 0.5, 0.5) + ctx.rectangle(x0, y0, width, height) + ctx.fill() + + for used in result.used: + if used.panel != panel: + continue + _write_used(surface, scale, x0, y0, used) + + for unused in result.unused: + if unused.panel != panel: + continue + _write_unused(surface, scale, x0, y0, unused) + + _write_centered_text(surface, settings.pagesize[0] / 2, + settings.pagesize[1] - settings.margin_bottom / 2, + panel.id) + + +def _write_used(surface, scale, x0, y0, used): + width = used.item.width * scale + height = used.item.height * scale + if used.rotate: + width, height = height, width + x = x0 + used.x * scale + y = y0 + used.y * scale + + ctx = cairo.Context(surface) + ctx.set_line_width(0) + ctx.set_source_rgb(0.9, 0.9, 0.9) + ctx.rectangle(x, y, width, height) + ctx.fill() + + _write_centered_text(surface, x + width / 2, y + height / 2, + used.item.id + (' (r)' if used.rotate else '')) + + +def _write_unused(surface, scale, x0, y0, unused): + width = unused.width * scale + height = unused.height * scale + x = x0 + unused.x * scale + y = y0 + unused.y * scale + + ctx = cairo.Context(surface) + ctx.set_line_width(0) + ctx.set_source_rgb(0.7, 0.7, 0.7) + ctx.rectangle(x, y, width, height) + ctx.fill() + + +def _write_centered_text(surface, x, y, text): + ctx = cairo.Context(surface) + ctx.set_source_rgb(0, 0, 0) + text_ext = ctx.text_extents(text) + ctx.move_to(x - text_ext.width / 2, + y + text_ext.height / 2) + ctx.show_text(text) + + +def _calculate_scale(settings, panel): + page_width = (settings.pagesize[0] - settings.margin_left - + settings.margin_right) + page_height = (settings.pagesize[1] - settings.margin_top - + settings.margin_bottom) + page_ratio = page_width / page_height + panel_ratio = panel.width / panel.height + return (page_width / panel.width if panel_ratio > page_ratio + else page_height / panel.height) diff --git a/src_py/opcut/main.py b/src_py/opcut/main.py index b1d66a8..f09fe71 100644 --- a/src_py/opcut/main.py +++ b/src_py/opcut/main.py @@ -1,4 +1,5 @@ from pathlib import Path +import argparse import asyncio import contextlib import logging.config @@ -7,11 +8,10 @@ import typing from hat import aio from hat import json -import click from opcut import common -import opcut.csp -import opcut.output +import opcut.calculate +import opcut.generate import opcut.server @@ -19,107 +19,153 @@ params_schema_id: str = 'opcut://opcut.yaml#/definitions/params' result_schema_id: str = 'opcut://opcut.yaml#/definitions/result' -def _doc_enum_values(enum_cls): - return ', '.join(str(i.value) for i in enum_cls) +def create_argument_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser() + subparsers = parser.add_subparsers(dest='action') + + def enum_values(enum_cls): + return ', '.join(str(i.value) for i in enum_cls) + + calculate = subparsers.add_parser('calculate') + calculate.add_argument( + '--method', metavar='METHOD', type=common.Method, + default=common.Method.FORWARD_GREEDY, + help=f"calculate method ({enum_values(common.Method)})") + calculate.add_argument( + '--input-format', metavar='FORMAT', type=json.Format, default=None, + help=f"input params format ({enum_values(json.Format)})") + calculate.add_argument( + '--output-format', metavar='FORMAT', type=json.Format, default=None, + help=f"output result format ({enum_values(json.Format)})") + calculate.add_argument( + '--output', metavar='PATH', type=Path, default=Path('-'), + help=f"output result file path or - for stdout ({result_schema_id})") + calculate.add_argument( + 'params', type=Path, default=Path('-'), + help=f"input params file path or - for stdin ({params_schema_id})") + + generate = subparsers.add_parser('generate') + generate.add_argument( + '--input-format', metavar='FORMAT', type=json.Format, default=None, + help=f"input result format ({enum_values(json.Format)})") + generate.add_argument( + '--output-format', metavar='FORMAT', type=common.OutputFormat, + default=common.OutputFormat.PDF, + help=f"output format ({enum_values(common.OutputFormat)})") + generate.add_argument( + '--panel', metavar='PANEL', default=None, + help="panel identifier") + generate.add_argument( + '--output', metavar='PATH', type=Path, default=Path('-'), + help="output file path or - for stdout") + generate.add_argument( + 'result', type=Path, default=Path('-'), + help=f"input result file path or - for stdin ({result_schema_id})") + + server = subparsers.add_parser('server') + server.add_argument( + '--host', metavar='HOST', default='0.0.0.0', + help="listening host name (default 0.0.0.0)") + server.add_argument( + '--port', metavar='PORT', type=int, default=8080, + help="listening TCP port (default 8080)") + server.add_argument( + '--log-level', metavar='LEVEL', default='info', + choices=['critical', 'error', 'warning', 'info', 'debug', 'notset'], + help="log level (default info)") + + return parser -@click.group() def main(): - """Application main entry point""" - - -@main.command() -@click.option('--method', - default=common.Method.FORWARD_GREEDY, - type=common.Method, - help=f"calculate method ({_doc_enum_values(common.Method)})") -@click.option('--output', - default=None, - metavar='PATH', - type=Path, - help=f"result file path ({result_schema_id})") -@click.argument('params', - required=False, - default=None, - metavar='PATH', - type=Path) + parser = create_argument_parser() + args = parser.parse_args() + + if args.action == 'calculate': + calculate(method=args.method, + input_format=args.input_format, + output_format=args.output_format, + result_path=args.output, + params_path=args.params) + + elif args.action == 'generate': + generate(input_format=args.input_format, + output_format=args.output_format, + panel_id=args.panel, + output_path=args.output, + result_path=args.result) + + elif args.action == 'server': + server(host=args.host, + port=args.port, + log_level=args.log_level) + + else: + raise ValueError('unsupported action') + + def calculate(method: common.Method, - output: typing.Optional[Path], - params: typing.Optional[Path]): - """Calculate result based on parameters JSON""" - params = (json.decode_file(params) if params and params != Path('-') - else json.decode_stream(sys.stdin)) - common.json_schema_repo.validate(params_schema_id, params) - params = common.params_from_json(params) + input_format: typing.Optional[json.Format], + output_format: typing.Optional[json.Format], + result_path: Path, + params_path: Path): + if input_format is None and params_path == Path('-'): + input_format = json.Format.JSON + + if output_format is None and result_path == Path('-'): + output_format = json.Format.JSON + + params_json = (json.decode_stream(sys.stdin, input_format) + if params_path == Path('-') + else json.decode_file(params_path, input_format)) + + common.json_schema_repo.validate(params_schema_id, params_json) + params = common.params_from_json(params_json) try: - res = opcut.csp.calculate(params, method) + result = opcut.calculate.calculate(method=method, + params=params) except common.UnresolvableError: sys.exit(42) - res = common.result_to_json(res) + result_json = common.result_to_json(result) - if output and output != Path('-'): - json.encode_file(res, output) - else: - json.encode_stream(res, sys.stdout) - - -@main.command() -@click.option('--output-type', - default=common.OutputType.PDF, - type=common.OutputType, - help=f"output type ({_doc_enum_values(common.OutputType)})") -@click.option('--panel', - default=None, - help="panel identifier") -@click.option('--output', - default=None, - metavar='PATH', - type=Path, - help="result file path") -@click.argument('result', - required=False, - default=None, - metavar='PATH', - type=Path) -def generate_output(output_type: common.OutputType, - panel: typing.Optional[str], - output: typing.Optional[Path], - result: typing.Optional[Path]): - """Generate output based on result JSON""" - result = (json.decode_file(result) if result and result != Path('-') - else json.decode_stream(sys.stdin)) - common.json_schema_repo.validate(result_schema_id, result) - result = common.result_from_json(result) - - out = opcut.output.generate_output(result, output_type, panel) - - if output and output != Path('-'): - output.write_bytes(out) + if result_path == Path('-'): + json.encode_stream(result_json, sys.stdout, output_format) else: + json.encode_file(result_json, result_path, output_format) + + +def generate(input_format: typing.Optional[json.Format], + output_format: common.OutputFormat, + panel_id: typing.Optional[str], + output_path: Path, + result_path: Path): + if input_format is None and result_path == Path('-'): + input_format = json.Format.JSON + + result_json = (json.decode_stream(sys.stdin, input_format) + if result_path == Path('-') + else json.decode_file(result_path, input_format)) + + common.json_schema_repo.validate(result_schema_id, result_json) + result = common.result_from_json(result_json) + + data = opcut.generate.generate(result=result, + output_format=output_format, + panel_id=panel_id) + + if output_path == Path('-'): stdout, sys.stdout = sys.stdout.detach(), None - stdout.write(out) - - -@main.command() -@click.option('--host', - default='0.0.0.0', - help="listening host name") -@click.option('--port', - default=8080, - type=int, - help="listening TCP port") -@click.option('--log-level', - default='INFO', - type=click.Choice(['CRITICAL', 'ERROR', 'WARNING', 'INFO', - 'DEBUG', 'NOTSET']), - help="log level") + stdout.write(data) + else: + output_path.write_bytes(data) + + def server(host: str, port: int, log_level: str): - """Run server""" logging.config.dictConfig({ 'version': 1, 'formatters': { @@ -129,14 +175,15 @@ def server(host: str, 'console': { 'class': 'logging.StreamHandler', 'formatter': 'console', - 'level': log_level}}, + 'level': log_level.upper()}}, 'root': { - 'level': log_level, + 'level': log_level.upper(), 'handlers': ['console']}, 'disable_existing_loggers': False}) async def run(): - server = await opcut.server.create(host, port) + server = await opcut.server.create(host=host, + port=port) try: await server.wait_closing() diff --git a/src_py/opcut/output.py b/src_py/opcut/output.py deleted file mode 100644 index 65f8096..0000000 --- a/src_py/opcut/output.py +++ /dev/null @@ -1,117 +0,0 @@ -import io -import typing - -import cairo - -from opcut import common - - -def generate_output(result: common.Result, - output_type: common.OutputType, - panel_id: typing.Optional[str] = None, - settings: common.OutputSettings = common.OutputSettings() - ) -> bytes: - """Generate output""" - ret = io.BytesIO() - - if output_type == common.OutputType.PDF: - surface_cls = cairo.PDFSurface - - elif output_type == common.OutputType.SVG: - surface_cls = cairo.SVGSurface - - else: - raise ValueError('unsupported output type') - - with surface_cls(ret, - settings.pagesize[0], - settings.pagesize[1]) as surface: - for panel in result.params.panels: - if panel_id and panel.id != panel_id: - continue - - _write_panel(surface, settings, result, panel) - surface.show_page() - - return ret.getvalue() - - -def _write_panel(surface, settings, result, panel): - scale = _calculate_scale(settings, panel) - width = panel.width * scale - height = panel.height * scale - x0 = ((settings.pagesize[0] - width) * settings.margin_left / - (settings.margin_left + settings.margin_right)) - y0 = ((settings.pagesize[1] - height) * settings.margin_top / - (settings.margin_top + settings.margin_bottom)) - - ctx = cairo.Context(surface) - ctx.set_line_width(0) - ctx.set_source_rgb(0.5, 0.5, 0.5) - ctx.rectangle(x0, y0, width, height) - ctx.fill() - - for used in result.used: - if used.panel != panel: - continue - _write_used(surface, scale, x0, y0, used) - - for unused in result.unused: - if unused.panel != panel: - continue - _write_unused(surface, scale, x0, y0, unused) - - _write_centered_text(surface, settings.pagesize[0] / 2, - settings.pagesize[1] - settings.margin_bottom / 2, - panel.id) - - -def _write_used(surface, scale, x0, y0, used): - width = used.item.width * scale - height = used.item.height * scale - if used.rotate: - width, height = height, width - x = x0 + used.x * scale - y = y0 + used.y * scale - - ctx = cairo.Context(surface) - ctx.set_line_width(0) - ctx.set_source_rgb(0.9, 0.9, 0.9) - ctx.rectangle(x, y, width, height) - ctx.fill() - - _write_centered_text(surface, x + width / 2, y + height / 2, - used.item.id + (' (r)' if used.rotate else '')) - - -def _write_unused(surface, scale, x0, y0, unused): - width = unused.width * scale - height = unused.height * scale - x = x0 + unused.x * scale - y = y0 + unused.y * scale - - ctx = cairo.Context(surface) - ctx.set_line_width(0) - ctx.set_source_rgb(0.7, 0.7, 0.7) - ctx.rectangle(x, y, width, height) - ctx.fill() - - -def _write_centered_text(surface, x, y, text): - ctx = cairo.Context(surface) - ctx.set_source_rgb(0, 0, 0) - text_ext = ctx.text_extents(text) - ctx.move_to(x - text_ext.width / 2, - y + text_ext.height / 2) - ctx.show_text(text) - - -def _calculate_scale(settings, panel): - page_width = (settings.pagesize[0] - settings.margin_left - - settings.margin_right) - page_height = (settings.pagesize[1] - settings.margin_top - - settings.margin_bottom) - page_ratio = page_width / page_height - panel_ratio = panel.width / panel.height - return (page_width / panel.width if panel_ratio > page_ratio - else page_height / panel.height) diff --git a/src_py/opcut/server.py b/src_py/opcut/server.py index cd7d048..939222c 100644 --- a/src_py/opcut/server.py +++ b/src_py/opcut/server.py @@ -1,6 +1,5 @@ from pathlib import Path import asyncio -import platform import subprocess import sys @@ -24,7 +23,7 @@ async def create(host: str, app.add_routes([ aiohttp.web.get('/', server._root_handler), aiohttp.web.post('/calculate', server._calculate_handler), - aiohttp.web.post('/generate_output', server._generate_output_handler), + aiohttp.web.post('/generate', server._generate_handler), aiohttp.web.static('/', static_dir)]) runner = aiohttp.web.AppRunner(app) @@ -65,19 +64,18 @@ class Server(aio.Resource): return aiohttp.web.Response(status=400, text="Invalid request") - native = request.query.get('native') == 'true' method = common.Method(request.query['method']) params = common.params_from_json(data) try: - result = await _calculate(native, method, params) + result = await _calculate(method, params) return aiohttp.web.json_response(result) except common.UnresolvableError: return aiohttp.web.Response(status=400, text='Result is not solvable') - async def _generate_output_handler(self, request): + async def _generate_handler(self, request): try: data = await request.json() common.json_schema_repo.validate( @@ -87,16 +85,16 @@ class Server(aio.Resource): return aiohttp.web.Response(status=400, text="Invalid request") - output_type = common.OutputType(request.query['output_type']) + output_format = common.OutputFormat(request.query['output_format']) panel = request.query.get('panel') result = common.result_from_json(data) - output = await _generate_output(output_type, panel, result) + output = await _generate(output_format, panel, result) - if output_type == common.OutputType.PDF: + if output_format == common.OutputType.PDF: content_type = 'application/pdf' - elif output_type == common.OutputType.SVG: + elif output_format == common.OutputType.SVG: content_type = 'image/svg+xml' else: @@ -106,8 +104,9 @@ class Server(aio.Resource): content_type=content_type) -async def _calculate(native, method, params): - args = [*_get_calculate_cmd(native), '--method', method.value] +async def _calculate(method, params): + args = [sys.executable, '-m', 'opcut', 'calculate', + '--method', method.value] stdint_data = json.encode(common.params_to_json(params)).encode('utf-8') p = await asyncio.create_subprocess_exec(*args, @@ -125,8 +124,9 @@ async def _calculate(native, method, params): raise Exception(stderr_data.decode('utf-8')) -async def _generate_output(output_type, panel, result): - args = [*_get_generate_output_cmd(), '--output-type', output_type.value, +async def _generate(output_format, panel, result): + args = [sys.executable, '-m', 'opcut', 'generate', + '--output-format', output_format.value, *(['--panel', panel] if panel else [])] stdint_data = json.encode(common.result_to_json(result)).encode('utf-8') @@ -140,21 +140,3 @@ async def _generate_output(output_type, panel, result): return stdout_data raise Exception(stderr_data.decode('utf-8')) - - -def _get_calculate_cmd(native): - if native and sys.platform == 'linux': - if platform.machine() == 'x86_64': - return [str(common.package_path / - 'bin/linux_x86_64-opcut-calculate')] - - elif native and sys.platform == 'win32': - if platform.machine() == 'amd64': - return [str(common.package_path / - 'bin/windows_amd64-opcut-calculate.exe')] - - return [sys.executable, '-m', 'opcut', 'calculate'] - - -def _get_generate_output_cmd(): - return [sys.executable, '-m', 'opcut', 'generate-output'] -- cgit v1.2.3-70-g09d2