diff --git a/docs/assets/images/block_diagram_example.png b/docs/assets/images/block_diagram_example.png new file mode 100644 index 00000000..03d1a648 Binary files /dev/null and b/docs/assets/images/block_diagram_example.png differ diff --git a/docs/assets/images/schematic_example.png b/docs/assets/images/schematic_example.png new file mode 100644 index 00000000..b377bbd6 Binary files /dev/null and b/docs/assets/images/schematic_example.png differ diff --git a/docs/view.md b/docs/view.md index ccb81d1b..18da437b 100644 --- a/docs/view.md +++ b/docs/view.md @@ -1,35 +1,50 @@ # atopile viewer -To use the viewer, start by building your project using: +To use the viewer, invoke the `ato view` cli command: ``` sh -ato build +ato view ``` -This will create a `.view.json` file that the viewer can consume. Invoke the viewer using: +If you have multiple build configuration, specify the one you would like to view with: ``` sh -ato view +ato view -b ``` -The viewer will spool up a server on your local machine at [http://127.0.0.1:8080](http://127.0.0.1:8080). The viewer gets access to the `.view.json` through http://127.0.0.1:8080/data. +The viewer will spool up a server on your local machine at [http://127.0.0.1:8080](http://127.0.0.1:8080). + +## Viewer interfaces + +### Block diagram + +![Block Diagram](assets/images/block_diagram_example.png) + +The block diagram is meant to provide a view that resembles your code structure. In the block diagram view, you will see the same signals and interfaces that are present in your code as well as how they interact with each other. This view will help you navigate through your project and it's structure. + +### Schematic + +![Schematic](assets/images/schematic_example.png) -## Viewer interface +The schematic view follows a more standard view of your design. This view can be used for documentation or inspecting a more concrete view of your final circuit. The schematic view can be enabled by navigating with the block diagram to the block you want to inspect and pressing the schematic button. You can switch back to block diagram by pressing the same button. -### Navigate within your design +The schematic diagram will represent all the components that are at the level or below the current module. -The left pane shows you the name of the instance you are currently viewing, the parent instance and provides two buttons: "return" gets you back to the parent module. "layout" recreates the default layout after you have moved blocks around. +## Navigate within your design To navigate within a module or component, simply click on it. +*return*: This button brings you back to the parent module +*re-layout*: This button re-lays out the modules for you +*schematic/block diagram*: Switch between the two viewing modes +*reload*: Loads the latest changes for your code. This feature hasn't been enabled from the block diagram yet. ### Inspect links -Clicking on a link will show the source and target address that the link is connection. Those could either be two signals or two compatible instances of an interface. +Clicking on a link in the block diagram will show the source and target address that the link is connection. Those could either be two signals or two compatible instances of an interface. ## Features currently not supported (but planned) -- Saving the position of blocks +- Saving the position of blocks and components - Inspecting a links pin to pin connections - Expanding and contracting modules (instead of navigating in and out of modules) -- A decent way to see components and their pins -- Ability to inspect multiple build configurations \ No newline at end of file +- A decent way to see components and their pins \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index eee692a5..4c5dc48e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,8 +39,8 @@ classifiers = [ dependencies = [ "antlr4-python3-runtime==4.13.0", "attrs>=23.2.0", - "cattrs>=23.2.3", "case-converter>=1.1.0", + "cattrs>=23.2.3", "click>=8.1.7", "DeepDiff>=6.7.1", "easyeda2ato>=0.2.4", @@ -50,22 +50,22 @@ dependencies = [ "igraph>=0.11.3", "jinja2>=3.1.3", "natsort>=8.4.0", + "networkx>=3.2.1", "packaging>=23.2", "pandas>=2.1.4", "pint>=0.23", "pygls>=1.3.0", + "quart-schema[pydantic]>=0.19.1", + "quart>=0.19.5", + "quart-cors>=0.7.0", "rich>=13.7.0", "ruamel.yaml>=0.18.5", - "scipy>=1.12.0", "schema>=0.7.5", + "scipy>=1.12.0", "semver>=3.0.2", "toolz>=0.12.0", "uvicorn[standard]>=0.25.0", "watchfiles>=0.21.0", - "networkx>=3.2.1", - "Flask>=3.0.2", - "Flask-Cors>=4.0.0", - "waitress>=3.0.0", ] dynamic = ["version"] diff --git a/src/atopile/address.py b/src/atopile/address.py index ff209527..ab4a21a9 100644 --- a/src/atopile/address.py +++ b/src/atopile/address.py @@ -7,6 +7,8 @@ """ from typing import Optional, Iterable from functools import wraps +from os import PathLike +from pathlib import Path class AddrStr(str): @@ -63,15 +65,16 @@ def get_file(address: AddrStr) -> str: return address.split(":")[0] -def get_relative_addr_str(address: AddrStr) -> AddrStr: +def get_relative_addr_str(address: AddrStr, base_path: PathLike) -> AddrStr: """ Extract the relative address starting with the .ato file /abs/path/to/file.ato:Entry.Path::instance.path -> file.ato:Entry.Path::instance.path - FIXME: relative is a little misleading, as it's not relative to anything in particular, - it's merely the address without the absolute path. + FIXME: this is the first and currently only place we're + using these relative addresses. We should codify them """ - return address.split("/")[-1] + rel_file = Path(get_file(address)).relative_to(base_path) + return from_parts(str(rel_file), get_entry_section(address), get_instance_section(address)) def get_entry(address: AddrStr) -> AddrStr: diff --git a/src/atopile/cli/build.py b/src/atopile/cli/build.py index ce2d90bc..79b0075c 100644 --- a/src/atopile/cli/build.py +++ b/src/atopile/cli/build.py @@ -17,12 +17,11 @@ import atopile.netlist import atopile.variable_report from atopile.cli.common import project_options -from atopile.components import configure_cache, download_footprint +from atopile.components import download_footprint from atopile.config import BuildContext from atopile.errors import handle_ato_errors, iter_through_errors from atopile.instance_methods import all_descendants, match_components from atopile.netlist import get_netlist_as_str -from atopile.viewer_utils import get_vis_dict log = logging.getLogger(__name__) @@ -59,12 +58,6 @@ def build(build_ctxs: list[BuildContext]): def _do_build(build_ctx: BuildContext) -> None: """Execute a specific build.""" - - # Configure the cache for component data - # TODO: flip this around so that the cache pulls what it needs from the - # project context - configure_cache(atopile.config.get_project_context().project_path) - # Solve the unknown variables atopile.assertions.simplify_expressions(build_ctx.entry) atopile.assertions.solve_assertions(build_ctx) @@ -219,16 +212,3 @@ def generate_module_map(build_args: BuildContext) -> None: def generate_assertion_report(build_ctx: BuildContext) -> None: """Generate a report based on assertions made in the source code.""" atopile.assertions.generate_assertion_report(build_ctx) - - -@muster.register("view-dict") -def generate_view_dict(build_ctx: BuildContext) -> None: - """Generate a dictionary for the viewer.""" - with open(build_ctx.output_base.with_suffix(".view.json"), "w", encoding="utf-8") as f: - f.write(get_vis_dict(build_ctx.entry)) - - -@muster.register("variable-report") -def generate_variable_report(build_ctx: BuildContext) -> None: - """Generate a report of all the variable values in the design.""" - atopile.variable_report.generate(build_ctx) diff --git a/src/atopile/cli/view.py b/src/atopile/cli/view.py index fe6e63da..2353c9ae 100644 --- a/src/atopile/cli/view.py +++ b/src/atopile/cli/view.py @@ -3,43 +3,132 @@ """ `ato view` """ - import logging +import textwrap +from enum import Enum +from pathlib import Path import click - -from flask import Flask, jsonify, send_from_directory -from flask_cors import CORS -import json -import os - -from waitress import serve - +import yaml +from pydantic import BaseModel +from quart import Quart, jsonify, send_from_directory +from quart_cors import cors +from quart_schema import QuartSchema, validate_request, validate_response +from watchfiles import awatch + +import atopile.address +import atopile.config +import atopile.front_end +import atopile.instance_methods +import atopile.schematic_utils +import atopile.viewer_utils from atopile import errors from atopile.cli.common import project_options -from atopile.config import BuildContext +from atopile.config import BuildContext, set_project_context log = logging.getLogger(__name__) log.setLevel(logging.INFO) -viewer_app = Flask(__name__, static_folder='../viewer/dist', static_url_path='') -# Enable CORS for all domains on all routes -CORS(viewer_app) +app = Quart(__name__, static_folder="../viewer/dist", static_url_path="") +app = cors(app, allow_origin="*") +QuartSchema(app) + + +@app.route("/block-diagram") +async def send_viewer_data(): + build_ctx: BuildContext = app.config["build_ctx"] + return jsonify(atopile.viewer_utils.get_vis_dict(build_ctx)) + + +class Pose(BaseModel): + """The position, orientation, flipping etc... of an element.""" + x: float + y: float + angle: int # degrees, but should only be 0, 90, 180, 270 + + +class DiagramType(str, Enum): + """The type of diagram.""" + block = "block" + schematic = "schematic" + + +@app.route("///pose", methods=["POST"]) +@validate_request(Pose) +@validate_response(Pose, 201) +async def save_pose( + diagram_type: str | DiagramType, + addr: str, + data: Pose +) -> tuple[Pose, int]: + """Save the pose of an element.""" + diagram_type = DiagramType(diagram_type) + build_ctx: BuildContext = app.config["build_ctx"] + addr = "/" + atopile.address.AddrStr(addr) + + # FIXME: rip this logic outta here + # We save the pose information to one file per-project + # FIXME: figure out how we should actually + # interact with these config files + lock_path = build_ctx.project_context.project_path / "ato-lock.yaml" + if lock_path.exists(): + with lock_path.open("r") as lock_file: + lock_data = yaml.safe_load(lock_file) or {} + else: + lock_data = {} + + # Find the relative address of the element to the project + rel_addr = atopile.address.get_relative_addr_str(addr, build_ctx.project_context.project_path) + lock_data.setdefault("poses", {}).setdefault(diagram_type.name, {})[rel_addr] = data.model_dump() -@viewer_app.route('/data') -def send_json(): - build_dir_path = viewer_app.config.get('build_dir_path', 'Default value if not set') - #TODO: handle other builds than defaults - view_data_path = os.path.join(build_dir_path, 'default.view.json') - with open(view_data_path, 'r') as file: - data = json.load(file) - return jsonify(data) + with lock_path.open("w") as lock_file: + yaml.safe_dump(lock_data, lock_file) + return data, 200 -@viewer_app.route('/') -def home(): - return send_from_directory(viewer_app.static_folder, 'index.html') + +@app.route("/schematic") +async def send_schematic_data(): + build_ctx: BuildContext = app.config["build_ctx"] + return jsonify(atopile.schematic_utils.get_schematic_dict(build_ctx)) + + +@app.route("/") +async def home(): + """Serve the home page.""" + return await send_from_directory(app.static_folder, "index.html") + + +def _ato_file_filter(_, path: str): + """Filter for files that are not ato files.""" + return path.endswith(".ato") + + +async def monitor_changes(src_dir: Path): + """Background task to monitor the project for changes.""" + log.info(f"Monitoring {src_dir} for changes") + + async for changes in awatch(src_dir, watch_filter=_ato_file_filter, recursive=True): + for change, file in changes: + log.log(logging.NOTSET, "Change detected in %s: %s", file, change.name) + atopile.front_end.reset_caches(file) + + +@app.before_serving +async def startup(): + """Set up the viewer.""" + log.info("Setting up the viewer") + build_ctx: BuildContext = app.config["build_ctx"] + + # Monitor the project for changes to reset caches + app.add_background_task( + monitor_changes, + build_ctx.project_context.project_path + ) + + # Pre-build the entry point + atopile.instance_methods.get_instance(build_ctx.entry) @click.command() @@ -47,15 +136,16 @@ def home(): def view(build_ctxs: list[BuildContext]): log.info("Spinning up the viewer") - if len(build_ctxs) == 0: - errors.AtoNotImplementedError("No build contexts found.") - elif len(build_ctxs) == 1: - build_ctx = build_ctxs[0] - else: - build_ctx = build_ctxs[0] - errors.AtoNotImplementedError( - f"Using top build config {build_ctx.name} for now. Multiple build configs not yet supported." - ).log(log, logging.WARNING) + if len(build_ctxs) != 1: + log.info(textwrap.dedent(""" + You need to select what you want to view. + - If you use the `--build` option, you will view the entry point of the build. + - If you add an argument for the address, you'll view that. + """)) + raise errors.AtoNotImplementedError("Multiple build configs not yet supported.") + + build_ctx = build_ctxs[0] + app.config["build_ctx"] = build_ctx + set_project_context(build_ctx.project_context) - viewer_app.config['build_dir_path'] = build_ctx.build_path - serve(viewer_app, host="127.0.0.1", port=8080) + app.run(host="127.0.0.1", port=8080) diff --git a/src/atopile/components.py b/src/atopile/components.py index e15ae8d3..9739a44f 100644 --- a/src/atopile/components.py +++ b/src/atopile/components.py @@ -57,38 +57,44 @@ class NoMatchingComponent(errors.AtoError): title = "No component matches parameters" -component_cache: dict[str, Any] -cache_file_path: Path +_component_cache: Optional[dict[str, Any]] = None +def get_component_cache() -> dict[str, Any]: + """Return the component cache.""" + global _component_cache + if _component_cache is None: + configure_cache() + return _component_cache -def configure_cache(top_level_path: Path): + +def configure_cache(): """Configure the cache to be used by the component module.""" - global component_cache - global cache_file_path - cache_file_path = top_level_path / ".ato/component_cache.json" + global _component_cache + cache_file_path = config.get_project_context().project_path / ".ato/component_cache.json" if cache_file_path.exists(): with open(cache_file_path, "r") as cache_file: - component_cache = json.load(cache_file) + _component_cache = json.load(cache_file) # Clean out stale entries clean_cache() else: - component_cache = {} + _component_cache = {} def save_cache(): """Saves the current state of the cache to a file.""" + cache_file_path = config.get_project_context().project_path / ".ato/component_cache.json" cache_file_path.parent.mkdir(parents=True, exist_ok=True) with open(cache_file_path, "w") as cache_file: # Convert the ChainMap to a regular dictionary - serializable_cache = dict(component_cache) + serializable_cache = dict(get_component_cache()) json.dump(serializable_cache, cache_file) def get_component_from_cache(component_addr: AddrStr, current_data: dict) -> Optional[dict]: """Retrieve a component from the cache, if available, not stale, and unchanged.""" - if component_addr not in component_cache: + # Check the cache age + cached_entry = get_component_cache().get(component_addr) + if not cached_entry: return None - # Check the cache age - cached_entry = component_cache[component_addr] cached_timestamp = datetime.fromtimestamp(cached_entry["timestamp"]) cache_age = datetime.now() - cached_timestamp if cache_age > timedelta(days=14): @@ -104,7 +110,7 @@ def get_component_from_cache(component_addr: AddrStr, current_data: dict) -> Opt def update_cache(component_addr, component_data, address_data): """Update the cache with new component data and save it.""" - component_cache[component_addr] = { + get_component_cache()[component_addr] = { "data": component_data, "timestamp": time.time(), # Current time as a timestamp "address_data": dict(address_data), # Source attributes used to detect changes @@ -115,6 +121,7 @@ def update_cache(component_addr, component_data, address_data): def clean_cache(): """Clean out entries older than 1 day.""" addrs_to_delete = set() + component_cache = get_component_cache() for addr, entry in component_cache.items(): cached_timestamp = datetime.fromtimestamp(entry["timestamp"]) if datetime.now() - cached_timestamp >= timedelta(days=1): diff --git a/src/atopile/errors.py b/src/atopile/errors.py index 0e0646ef..2f25eaa1 100644 --- a/src/atopile/errors.py +++ b/src/atopile/errors.py @@ -186,7 +186,10 @@ def format_error(ex: AtoError, debug: bool = False) -> str: if debug: addr = ex.addr else: - addr = address.get_relative_addr_str(ex.addr) + addr = address.add_entry( + Path(address.get_file(ex.addr)).name, + address.get_entry_section(ex.addr) + ) # FIXME: we ignore the escaping of the address here fmt_addr = f"[bold cyan]{addr}[/]" diff --git a/src/atopile/front_end.py b/src/atopile/front_end.py index b2427648..43526480 100644 --- a/src/atopile/front_end.py +++ b/src/atopile/front_end.py @@ -1452,19 +1452,18 @@ def visitArithmetic_expression(self, ctx: ap.Arithmetic_expressionContext): def reset_caches(file: Path | str): """Remove a file from the cache.""" - if file in parser.cache: - del parser.cache[file] - - # TODO: only clear these caches of what's been invalidated file_str = str(file) + if file_str in parser.cache: + del parser.cache[file_str] + def _clear_cache(cache: dict[str, Any]): # We do this in two steps to avoid modifying # the dict while iterating over it for addr in list(filter(lambda addr: addr.startswith(file_str), cache)): del cache[addr] - _clear_cache(lofty._output_cache) + _clear_cache(scoop._output_cache) _clear_cache(dizzy._output_cache) lofty._output_cache.clear() diff --git a/src/atopile/netlist.py b/src/atopile/netlist.py index fc51ea9f..ba3a9cdb 100644 --- a/src/atopile/netlist.py +++ b/src/atopile/netlist.py @@ -4,7 +4,7 @@ from jinja2 import Environment, FileSystemLoader, StrictUndefined from toolz import groupby -from atopile import components, errors, nets, layout +from atopile import components, errors, nets, layout, config from atopile.address import AddrStr, get_name, get_relative_addr_str from atopile.instance_methods import ( all_descendants, @@ -56,7 +56,7 @@ def make_libpart(self, comp_addr: AddrStr) -> KicadLibpart: # return instance.origin super_abs_addr = get_next_super(comp_addr).obj_def.address - super_addr = get_relative_addr_str(super_abs_addr) + super_addr = get_relative_addr_str(super_abs_addr, config.get_project_context().project_path) constructed_libpart = KicadLibpart( part=_get_mpn(comp_addr), description=super_addr, diff --git a/src/atopile/parse.py b/src/atopile/parse.py index 4f1737e7..dfff905e 100644 --- a/src/atopile/parse.py +++ b/src/atopile/parse.py @@ -1,5 +1,6 @@ import logging from contextlib import contextmanager +from os import PathLike from pathlib import Path from antlr4 import CommonTokenStream, FileStream, InputStream @@ -8,7 +9,7 @@ from atopile.parser.AtopileLexer import AtopileLexer from atopile.parser.AtopileParser import AtopileParser -from .errors import AtoSyntaxError, AtoFileNotFoundError +from .errors import AtoFileNotFoundError, AtoSyntaxError log = logging.getLogger(__name__) log.setLevel(logging.INFO) @@ -92,16 +93,19 @@ def __init__(self) -> None: self.cache = {} def get_ast_from_file( - self, src_path: str | Path + self, src_origin: PathLike ) -> AtopileParser.File_inputContext: """Get the AST from a file.""" - if src_path not in self.cache: - src_path = Path(src_path) - if not src_path.exists(): - raise AtoFileNotFoundError(str(src_path)) - self.cache[src_path] = parse_file(src_path) - return self.cache[src_path] + src_origin_str = str(src_origin) + src_origin_path = Path(src_origin) + + if src_origin_str not in self.cache: + if not src_origin_path.exists(): + raise AtoFileNotFoundError(src_origin_str) + self.cache[src_origin_str] = parse_file(src_origin_path) + + return self.cache[src_origin_str] parser = FileParser() diff --git a/src/atopile/schematic_utils.py b/src/atopile/schematic_utils.py new file mode 100644 index 00000000..99db819a --- /dev/null +++ b/src/atopile/schematic_utils.py @@ -0,0 +1,272 @@ +import logging + +from atopile.address import AddrStr, get_name, add_instance, get_entry_section, get_relative_addr_str +from atopile.instance_methods import ( + get_children, + get_links, + get_supers_list, + all_descendants, + get_parent, + match_modules, + match_components, + match_interfaces, + match_signals, + match_pins_and_signals +) +from atopile.front_end import Link +from atopile import errors +import atopile.config + +from atopile.viewer_utils import get_id + +import json + +from typing import Optional + +import hashlib + +from atopile.components import get_specd_value, MissingData + +log = logging.getLogger(__name__) + + +_get_specd_value = errors.downgrade(get_specd_value, MissingData) + +#FIXME: this function is a reimplementation of the one in instance methods, since I don't have access to the std lib +# Diff is the additon of get_name(...) +def find_matching_super( + addr: AddrStr, candidate_supers: list[AddrStr] +) -> Optional[AddrStr]: + """ + Return the first super of addr, is in the list of + candidate_supers, or None if none are. + """ + supers = get_supers_list(addr) + for duper in supers: + if any(get_name(duper.address) == pt for pt in candidate_supers): + return duper.address + return None + +def get_std_lib(addr: AddrStr) -> str: + #TODO: The time has come to bake the standard lib as a compiler dependency... + std_lib_supers = [ + "Resistor", + "Inductor", + "Capacitor", + "LED", + "Power", + "NPN", + "PNP", + "Diode", + "SchottkyDiode", + "ZenerDiode", + "NFET", + "PFET", + "Opamp"] + + # Handle signals + if match_signals(addr): + signal_parent = get_parent(addr) + if match_interfaces(signal_parent): + matching_super = find_matching_super(signal_parent, std_lib_supers) + if matching_super is not None: + if get_entry_section(matching_super) == "Power": + return "Power." + get_name(addr) + else: + return "" + + # handle components + matching_super = find_matching_super(addr, std_lib_supers) + if matching_super is None: + return "" + return get_name(matching_super) + +def get_schematic_dict(build_ctx: atopile.config.BuildContext) -> dict: + + return_json = {} + + for addr in all_descendants(build_ctx.entry): + if match_modules(addr) and not match_components(addr): + # Start by creating a list of all the components in the view and their ports. + # Ports consist of cluster of pins, signals and signals within interfaces + + # Component dict that we will return + components_dict: dict[AddrStr, dict] = {} + + # Those are all the modules at or below the module currently being inspected + blocks_at_and_below_view: list[AddrStr] = list(filter(match_modules, all_descendants(addr))) + blocks_at_and_below_view.extend(list(filter(match_interfaces, all_descendants(addr)))) + + links_at_and_below_view: list[Link] = [] + for module in blocks_at_and_below_view: + links_at_and_below_view.extend(list(get_links(module))) + + pins_and_signals_at_and_below_view = list(filter(match_pins_and_signals, all_descendants(addr))) + + # This is a map of connectables beneath components to their net cluster id + connectable_to_nets_map: dict[AddrStr, str] = {} + + signals_dict: dict[AddrStr, dict] = {} + + # We start exploring the modules + for block in blocks_at_and_below_view: + #TODO: provide error message if we can't handle the component + if match_components(block): + component = block + # There might be nested interfaces that we need to extract + blocks_at_or_below_component = list(filter(match_modules, all_descendants(component))) + # Extract all links at or below the current component and form nets + links_at_and_below_component = [] + for block in blocks_at_or_below_component: + links_at_and_below_component.extend(list(get_links(block))) + + pins_and_signals_at_and_below_component = list(filter(match_pins_and_signals, all_descendants(component))) + component_nets = find_nets(pins_and_signals_at_and_below_component, links_at_and_below_component) + + # Component ports + component_ports_dict: dict[int, dict[str, str]] = {} + for component_net_index, component_net in enumerate(component_nets): + # create a hash of the net + hash_object = hashlib.sha256() + json_string = json.dumps(component_net) + hash_object.update(json_string.encode()) + net_hash = hash_object.hexdigest()[:8] + + component_ports_dict[component_net_index] = { + "net_id": net_hash, + "name": '/'.join(map(get_name, component_net)) + } + + for connectable in component_net: + connectable_to_nets_map[connectable] = net_hash + + comp_addr = get_relative_addr_str(component, build_ctx.project_context.project_path) + components_dict[comp_addr] = { + "instance_of": get_name(get_supers_list(component)[0].obj_def.address), + "std_lib_id": get_std_lib(component), + "value": _get_specd_value(component), + "address": get_relative_addr_str(component, build_ctx.project_context.project_path), + "name": get_name(component), + "ports": component_ports_dict, + "rotation": 0, + "mirror": False} + + elif match_interfaces(block): + pass + + else: + #TODO: this only handles interfaces in the highest module, not in nested modules + interfaces_at_module = list(filter(match_interfaces, get_children(block))) + for interface in interfaces_at_module: + #TODO: handle signals or interfaces that are not power + signals_in_interface = list(filter(match_signals, get_children(interface))) + for signal in signals_in_interface: + signals_dict[signal] = { + "std_lib_id": get_std_lib(signal), + "instance_of": get_name(get_supers_list(interface)[0].obj_def.address), + "address": get_relative_addr_str(signal, build_ctx.project_context.project_path), + "name": get_name(get_parent(signal)) + "." + get_name(signal)} + + if get_std_lib(signal) != "none": + pass + #connectable_to_nets_map[signal] = signal + + signals_at_view = list(filter(match_signals, get_children(block))) + for signal in signals_at_view: + signals_dict[signal] = { + "std_lib_id": get_std_lib(signal), + "instance_of": "signal", + "address": get_relative_addr_str(signal, build_ctx.project_context.project_path), + "name": get_name(signal)} + + + + # This step is meant to remove the irrelevant signals and interfaces so that we + # don't show them in the viewer + nets_above_components = find_nets(pins_and_signals_at_and_below_view, links_at_and_below_view) + converted_nets_above_components = [] + for net in nets_above_components: + # Make it a set so we don't add multiple times the same hash + converted_net = set() + for connectable in net: + if connectable in connectable_to_nets_map: + converted_net.add(connectable_to_nets_map[connectable]) + converted_nets_above_components.append(list(converted_net)) + + # net_links = [] + # # for each net in the component_net_above_components, create a link between each of the nodes in the net + # for net in converted_nets_above_components: + # output_net = [] + # for conn in net: + # output_net.append(conn) + # net_links.append(output_net) + + instance = get_id(addr, build_ctx) + return_json[instance] = { + "components": components_dict, + "signals": signals_dict, + "nets": converted_nets_above_components + } + + return return_json + + +#TODO: copied over from `ato inspect`. We probably need to deprecate `ato inspect` anyways and move this function +# to a common location +def find_nets(pins_and_signals: list[AddrStr], links: list[Link]) -> list[list[AddrStr]]: + """ + pins_and_signals: list of all the pins and signals that are expected to end up in the net + links: links that connect the pins_and_signals_together + """ + # Convert links to an adjacency list + graph = {} + for pin_and_signal in pins_and_signals: + graph[pin_and_signal] = [] + + # Short the pins and signals to each other on the first run to make sure they are recorded as nets + for link in links: + source = [] + target = [] + if match_interfaces(link.source.addr) and match_interfaces(link.target.addr): + for int_pin in get_children(link.source.addr): + if match_pins_and_signals(int_pin): + source.append(int_pin) + target.append(add_instance(link.target.addr, get_name(int_pin))) + else: + raise errors.AtoNotImplementedError("Cannot nest interfaces yet.") + elif match_interfaces(link.source.addr) or match_interfaces(link.target.addr): + # If only one of the nodes is an interface, then we need to throw an error + raise errors.AtoTypeError.from_ctx( + link.src_ctx, + f"Cannot connect an interface to a non-interface: {link.source.addr} ~ {link.target.addr}" + ) + # just a single link + else: + source.append(link.source.addr) + target.append(link.target.addr) + + for source, target in zip(source, target): + if source not in graph: + graph[source] = [] + if target not in graph: + graph[target] = [] + graph[source].append(target) + graph[target].append(source) + + + def dfs(node, component): + visited.add(node) + component.append(node) + for neighbor in graph.get(node, []): + if neighbor not in visited: + dfs(neighbor, component) + + connected_components = [] + visited = set() + for node in graph: + if node not in visited: + component = [] + dfs(node, component) + connected_components.append(component) + + return connected_components \ No newline at end of file diff --git a/src/atopile/viewer/index.html b/src/atopile/viewer/index.html index a0a99434..453471de 100644 --- a/src/atopile/viewer/index.html +++ b/src/atopile/viewer/index.html @@ -1,21 +1,23 @@ - - - - - atopile viewer - - - -
- - + + + + + atopile viewer + + + + +
+ + diff --git a/src/atopile/viewer/package-lock.json b/src/atopile/viewer/package-lock.json index fa165c0c..d30beac2 100644 --- a/src/atopile/viewer/package-lock.json +++ b/src/atopile/viewer/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "elkjs": "^0.9.2", "react": "^18.2.0", + "react-data-grid": "^7.0.0-beta.43", "react-dom": "^18.2.0", "react-router-dom": "^6.22.3", "reactflow": "^11.11.2" @@ -1961,6 +1962,14 @@ "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz", "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==" }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "engines": { + "node": ">=6" + } + }, "node_modules/color-convert": { "version": "1.9.3", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", @@ -3174,9 +3183,9 @@ ] }, "node_modules/react": { - "version": "18.2.0", - "resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz", - "integrity": "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==", + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "dependencies": { "loose-envify": "^1.1.0" }, @@ -3184,6 +3193,18 @@ "node": ">=0.10.0" } }, + "node_modules/react-data-grid": { + "version": "7.0.0-beta.43", + "resolved": "https://registry.npmjs.org/react-data-grid/-/react-data-grid-7.0.0-beta.43.tgz", + "integrity": "sha512-uqzhXsaeIpCnNsB1zODWzP88od6r5Q5UA5GnEhba9XmUMFUy2VcUTTfABmbAiVGdJkUzWJTx1l0hZn0WNGE/hQ==", + "dependencies": { + "clsx": "^2.0.0" + }, + "peerDependencies": { + "react": "^18.0", + "react-dom": "^18.0" + } + }, "node_modules/react-dom": { "version": "18.2.0", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz", @@ -3342,9 +3363,9 @@ } }, "node_modules/scheduler": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.0.tgz", - "integrity": "sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw==", + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", "dependencies": { "loose-envify": "^1.1.0" } diff --git a/src/atopile/viewer/package.json b/src/atopile/viewer/package.json index 16ed0b4c..cda6b4e1 100644 --- a/src/atopile/viewer/package.json +++ b/src/atopile/viewer/package.json @@ -11,6 +11,7 @@ "dependencies": { "elkjs": "^0.9.2", "react": "^18.2.0", + "react-data-grid": "^7.0.0-beta.43", "react-dom": "^18.2.0", "react-router-dom": "^6.22.3", "reactflow": "^11.11.2" diff --git a/src/atopile/viewer/src/App.tsx b/src/atopile/viewer/src/App.tsx index e0da2816..496b634b 100644 --- a/src/atopile/viewer/src/App.tsx +++ b/src/atopile/viewer/src/App.tsx @@ -1,284 +1,113 @@ // @ts-nocheck import React, { useCallback, useEffect, useLayoutEffect, useState } from 'react'; import ReactFlow, { - addEdge, - Background, - useNodesState, - useEdgesState, - MarkerType, - useReactFlow, ReactFlowProvider, Panel, - Position, - isEdge, - Edge } from 'reactflow'; -import 'reactflow/dist/style.css'; -import { createNodesAndEdges } from './utils.tsx'; -import { CustomNodeBlock, CircularNodeComponent, BuiltInNodeBlock } from './CustomNode.tsx'; -import CustomEdge from './CustomEdge.tsx'; +import AtopileSchematicApp from './SchematicApp.tsx'; +import AtopileBlockDiagramApp from './BlockDiagramApp.tsx'; -import SimpleTable from './LinkTable.tsx'; -import './index.css'; +let activeApp; -import ELK from 'elkjs/lib/elk.bundled.js'; +const App = () => { + const [viewBlockId, setViewBlockId] = useState('root'); + const [parentBlockId, setParentBlockId] = useState('none'); + const [reLayout, setReLayout] = useState(false); + const [reload, setReload] = useState(false); + const [schematicModeEnabled, setSchematicModeEnabled] = useState(false); -const { nodes: initialNodes, edges: initialEdges } = createNodesAndEdges(); - - -const elk = new ELK(); - -// Elk has a *huge* amount of options to configure. To see everything you can -// tweak check out: -// -// - https://www.eclipse.org/elk/reference/algorithms.html -// - https://www.eclipse.org/elk/reference/options.html -const elkOptions = { - 'elk.algorithm': 'layered', - 'elk.layered.spacing.nodeNodeBetweenLayers': '100', - 'elk.spacing.nodeNode': '80', -}; - -const getLayoutedElements = (nodes, edges, options = {}) => { - const isHorizontal = options?.['elk.direction'] === 'RIGHT'; - const graph = { - id: 'root', - layoutOptions: options, - children: nodes.map((node) => ({ - ...node, - // Adjust the target and source handle positions based on the layout - // direction. - targetPosition: isHorizontal ? 'left' : 'top', - sourcePosition: isHorizontal ? 'right' : 'bottom', - - // Hardcode a width and height for elk to use when layouting. - width: 200, - height: 50, - })), - edges: edges, -}; - - return elk - .layout(graph) - .then((layoutedGraph) => ({ - nodes: layoutedGraph.children.map((node) => ({ - ...node, - // React Flow expects a position property on the node instead of `x` - // and `y` fields. - position: { x: node.x, y: node.y }, - })), - - edges: layoutedGraph.edges, - })) - .catch(console.error); -}; - -const nodeTypes = { - customNode: CustomNodeBlock, - customCircularNode: CircularNodeComponent, - builtinNode: BuiltInNodeBlock, -}; - -const edgeTypes = { - custom: CustomEdge, -}; - -async function loadJsonAsDict() { - const response = await fetch('http://127.0.0.1:8080/data'); - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); + function handleReturnClick() { + if (parentBlockId === 'none' || parentBlockId === 'null') { + console.log('no parent block id'); + return; + } + setViewBlockId(parentBlockId); } - return response.json(); -} - -const block_id = "root"; -const parent_block_addr = "none"; -const selected_link_data = []; -const selected_link_source = "none"; -const selected_link_target = "none"; -const requestRelayout = false; -let selected_links_data = {}; - - -const AtopileViewer = () => { - const [nodes, setNodes, onNodesChange] = useNodesState([]); - const [edges, setEdges, onEdgesChange] = useEdgesState([]); - const [requestRelayout, setRequestRelayout] = useState(false); - const { fitView } = useReactFlow(); - const [block_id, setBlockId] = useState("root"); - const [parent_block_addr, setParentBlockAddr] = useState("none"); - const [selected_link_id, setSelectedLinkId] = useState("none"); - const [selected_link_data, setSelectedLinkData] = useState([]); - const [selected_link_source, setSelectedLinkSource] = useState("none"); - const [selected_link_target, setSelectedLinkTarget] = useState("none"); - - const handleExpandClick = (newBlockId) => { - setSelectedLinkId("none"); - setSelectedLinkData([]); - setSelectedLinkSource("none"); - setSelectedLinkTarget("none"); - setBlockId(newBlockId); - }; - - const handleLinkSelectClick = (newSelectedLinkId) => { - setSelectedLinkId(newSelectedLinkId); - setSelectedLinkData(selected_links_data[newSelectedLinkId]['links']); - setSelectedLinkSource(selected_links_data[newSelectedLinkId]['source']); - setSelectedLinkTarget(selected_links_data[newSelectedLinkId]['target']); - }; - - const onLayout = useCallback( - ({ direction }) => { - const opts = { 'elk.direction': direction, ...elkOptions }; - - getLayoutedElements(nodes, edges, opts).then(({ nodes: layoutedNodes, edges: layoutedEdges }) => { - setNodes(layoutedNodes); - setEdges(layoutedEdges); - - window.requestAnimationFrame(() => fitView()); - }); - }, [edges] ); + function handleExploreClick(block_id: string) { + setViewBlockId(block_id); + } + function handleBlockLoad(parent_block_id: string) { + setParentBlockId(parent_block_id); + setReLayout(true); + } - useEffect(() => { - const updateNodesFromJson = async () => { - try { - const fetchedNodes = await loadJsonAsDict(); - const displayedNode = fetchedNodes[block_id]; - setParentBlockAddr(displayedNode['parent']); - const populatedNodes = []; - for (const node in displayedNode['blocks']) { - const position = { - x: Math.random() * window.innerWidth, - y: Math.random() * window.innerHeight, - }; - let style; - if (displayedNode['blocks'][node]['type'] == 'signal') { - populatedNodes.push({ id: node, type: 'customCircularNode', data: { title: node, instance_of: displayedNode['blocks'][node]['instance_of'], color: '#8ECAE6' }, position: position }); - } else if (displayedNode['blocks'][node]['type'] == 'interface') { - populatedNodes.push({ id: node, type: 'customCircularNode', data: { title: node, instance_of: displayedNode['blocks'][node]['instance_of'], color: '#219EBC' }, position: position }); - } else if (displayedNode['blocks'][node]['type'] == 'module') { - populatedNodes.push({ id: node, type: 'customNode', data: { title: node, instance_of: displayedNode['blocks'][node]['instance_of'], address: displayedNode['blocks'][node]['address'], type: displayedNode['blocks'][node]['type'], color: '#FB8500', handleExpandClick: handleExpandClick }, sourcePosition: Position.Bottom, targetPosition: Position.Right, position: position }); - } else if (displayedNode['blocks'][node]['type'] == 'builtin') { - populatedNodes.push({ id: node, type: 'builtinNode', data: { - title: node, - instance_of: displayedNode['blocks'][node]['instance_of'], - address: displayedNode['blocks'][node]['address'], - type: displayedNode['blocks'][node]['type'], - value: displayedNode['blocks'][node]['value'], - lib_key: displayedNode['blocks'][node]['lib_key'], - color: '#FFFFFF', handleExpandClick: handleExpandClick }, - sourcePosition: Position.Bottom, - targetPosition: Position.Right, - position: position }); - } else { - populatedNodes.push({ id: node, type: 'customNode', data: { title: node, instance_of: displayedNode['blocks'][node]['instance_of'], address: displayedNode['blocks'][node]['address'], type: displayedNode['blocks'][node]['type'], color: '#FFB703', handleExpandClick: handleExpandClick }, sourcePosition: Position.Bottom, targetPosition: Position.Right, position: position }); - } - } - // Assuming fetchedNodes is an array of nodes in the format expected by React Flow - setNodes(populatedNodes); - const populatedEdges = []; - selected_links_data = {}; - for (const edge_id in displayedNode['harnesses']) { - const edge = displayedNode['harnesses'][edge_id]; + function handleReLayout() { + setReLayout(true); + } - // for each edge_id, update the data structure with the list of links on that harness - selected_links_data[edge_id] = {source: edge['source'], target: edge['target'], links: edge['links']}; + function reLayoutCleared() { + setReLayout(false); + } - // create a react edge element for each harness - populatedEdges.push({ - id: edge_id, - source: edge['source'], - target: edge['target'], - type: 'custom', - sourcePosition: Position.Right, - targetPosition: Position.Left, - markerEnd: { - type: MarkerType.Arrow, - }, - data: { - source: edge['source'], - target: edge['target'], - name: edge['name'], - preview_names: edge['preview_names'], - } - }); - } - setEdges(populatedEdges); - setRequestRelayout(true); - } catch (error) { - console.error("Failed to fetch nodes:", error); - } - }; + function handleModeSwitch() { + setSchematicModeEnabled(!schematicModeEnabled); + } - updateNodesFromJson(); - }, [block_id]); + function handleSavePos() { + console.log('save pos'); + savePos("/Users/timot/Dev/atopile/community-projects/viewer-test/elec/src/viewer-test.ato:ViewerTest::amp") + } - // Calculate the initial layout on mount. - useLayoutEffect(() => { - if (requestRelayout) { - onLayout({ direction: 'DOWN' }); - console.log('Relayout requested'); - setRequestRelayout(false); + async function savePos(addr, pos, angle) { + const mode = schematicModeEnabled ? 'schematic' : 'block-diagram'; + const url = `http://127.0.0.1:8080/${mode}/${addr}/pose`; + const response = await fetch(url, { + method: 'POST', // Set the method to POST + headers: { + 'Content-Type': 'application/json' // Set the content type header for sending JSON + }, + body: JSON.stringify({ + "angle": angle, + "x": pos.x, + "y": pos.y + }) + }); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); } - }, [edges]); + return response.json(); + } + + activeApp = schematicModeEnabled ? + + : + ; - const onSelectionChange = (elements) => { - // Filter out the selected edges from the selection - const selectedEdge = elements['edges'][0]; - // check if there is a selected edge - if (selectedEdge && !requestRelayout) { - setSelectedLinkId(selectedEdge.id); - setSelectedLinkData(selected_links_data[selectedEdge.id]['links']); - setSelectedLinkSource(selected_links_data[selectedEdge.id]['source']); - setSelectedLinkTarget(selected_links_data[selectedEdge.id]['target']); - } else if (selected_link_id != "none") { - setSelectedLinkId("none"); - setSelectedLinkData([]); - setSelectedLinkSource("none"); - setSelectedLinkTarget("none"); - } - }; return ( -
- - -
-
Model inspection pane
-
Inspecting: {block_id}
-
Parent: {parent_block_addr}
- - -
-
- - - - -
-
+ <> + + {activeApp} + +
+
Model inspection pane
+
Inspecting: {viewBlockId}
+
Parent: {parentBlockId}
+
Mode: {schematicModeEnabled ? 'schematic' : 'block diagram'}
+ + + + +
+
+
+ ); -}; - -// export default NodeAsHandleFlow; +} -export default () => ( - - - -); +export default App; diff --git a/src/atopile/viewer/src/BlockDiagramApp.tsx b/src/atopile/viewer/src/BlockDiagramApp.tsx new file mode 100644 index 00000000..879b6489 --- /dev/null +++ b/src/atopile/viewer/src/BlockDiagramApp.tsx @@ -0,0 +1,240 @@ +// @ts-nocheck +import React, { useCallback, useEffect, useLayoutEffect, useState } from 'react'; +import ReactFlow, { + addEdge, + Background, + useNodesState, + useEdgesState, + MarkerType, + useReactFlow, + ReactFlowProvider, + Panel, + Position, + isEdge, + Edge +} from 'reactflow'; +import 'reactflow/dist/style.css'; + +import { CustomNodeBlock, CircularNodeComponent } from './CustomNode.tsx'; +import CustomEdge from './CustomEdge.tsx'; + +import SimpleTable from './LinkTable.tsx'; + +import './index.css'; + +import ELK from 'elkjs/lib/elk.bundled.js'; + +import "react-data-grid/lib/styles.css"; + + +const elk = new ELK(); + +// Elk has a *huge* amount of options to configure. To see everything you can +// tweak check out: +// +// - https://www.eclipse.org/elk/reference/algorithms.html +// - https://www.eclipse.org/elk/reference/options.html +const elkOptions = { + 'elk.algorithm': 'layered', + 'elk.layered.spacing.nodeNodeBetweenLayers': '100', + 'elk.spacing.nodeNode': '80', +}; + +const getLayoutedElements = (nodes, edges, options = {}) => { + const isHorizontal = options?.['elk.direction'] === 'RIGHT'; + const graph = { + id: 'root', + layoutOptions: options, + children: nodes.map((node) => ({ + ...node, + // Adjust the target and source handle positions based on the layout + // direction. + targetPosition: isHorizontal ? 'left' : 'top', + sourcePosition: isHorizontal ? 'right' : 'bottom', + + // Hardcode a width and height for elk to use when layouting. + width: 200, + height: 50, + })), + edges: edges, +}; + + return elk + .layout(graph) + .then((layoutedGraph) => ({ + nodes: layoutedGraph.children.map((node) => ({ + ...node, + // React Flow expects a position property on the node instead of `x` + // and `y` fields. + position: { x: node.x, y: node.y }, + })), + + edges: layoutedGraph.edges, + })) + .catch(console.error); +}; + +const nodeTypes = { + customNode: CustomNodeBlock, + customCircularNode: CircularNodeComponent +}; + +const edgeTypes = { + custom: CustomEdge, +}; + +async function loadJsonAsDict() { + const response = await fetch('http://127.0.0.1:8080/block-diagram'); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + return response.json(); +} + +const selected_link_data = []; +const selected_link_source = "none"; +const selected_link_target = "none"; +const requestRelayout = false; +let selected_links_data = {}; + + +const AtopileBlockDiagramApp = ({ viewBlockId, handleBlockLoad, handleExploreClick, reLayout, reLayoutCleared, savePos }) => { + const [nodes, setNodes, onNodesChange] = useNodesState([]); + const [edges, setEdges, onEdgesChange] = useEdgesState([]); + const { fitView } = useReactFlow(); + const [selected_link_id, setSelectedLinkId] = useState("none"); + const [selected_link_data, setSelectedLinkData] = useState([]); + const [selected_link_source, setSelectedLinkSource] = useState("none"); + const [selected_link_target, setSelectedLinkTarget] = useState("none"); + + const handleLinkSelectClick = (newSelectedLinkId) => { + setSelectedLinkId(newSelectedLinkId); + setSelectedLinkData(selected_links_data[newSelectedLinkId]['links']); + setSelectedLinkSource(selected_links_data[newSelectedLinkId]['source']); + setSelectedLinkTarget(selected_links_data[newSelectedLinkId]['target']); + }; + + useEffect(() => { + onLayout({ direction: "DOWN" }); + reLayoutCleared(); + }, [reLayout]) + + const onLayout = useCallback( + ({ direction }) => { + const opts = { 'elk.direction': direction, ...elkOptions }; + getLayoutedElements(nodes, edges, opts).then(({ nodes: layoutedNodes, edges: layoutedEdges }) => { + setNodes(layoutedNodes); + setEdges(layoutedEdges); + + window.requestAnimationFrame(() => fitView()); + }); + }, [edges] ); + + useEffect(() => { + const updateNodesFromJson = async () => { + try { + const fetchedNodes = await loadJsonAsDict(); + const displayedNode = fetchedNodes[viewBlockId]; + handleBlockLoad(displayedNode['parent']); + const populatedNodes = []; + for (const node in displayedNode['blocks']) { + const position = { + x: Math.random() * window.innerWidth, + y: Math.random() * window.innerHeight, + }; + let style; + if (displayedNode['blocks'][node]['type'] == 'signal') { + populatedNodes.push({ id: node, type: 'customCircularNode', data: { title: node, instance_of: displayedNode['blocks'][node]['instance_of'], color: '#8ECAE6' }, position: position }); + } else if (displayedNode['blocks'][node]['type'] == 'interface') { + populatedNodes.push({ id: node, type: 'customCircularNode', data: { title: node, instance_of: displayedNode['blocks'][node]['instance_of'], color: '#219EBC' }, position: position }); + } + else if (displayedNode['blocks'][node]['type'] == 'module') { + //TODO: change the name of the explore click + populatedNodes.push({ id: node, type: 'customNode', data: { title: node, instance_of: displayedNode['blocks'][node]['instance_of'], address: displayedNode['blocks'][node]['address'], type: displayedNode['blocks'][node]['type'], color: '#FB8500', handleExpandClick: handleExploreClick }, sourcePosition: Position.Bottom, targetPosition: Position.Right, position: position }); + } else { + populatedNodes.push({ id: node, type: 'customNode', data: { title: node, instance_of: displayedNode['blocks'][node]['instance_of'], address: displayedNode['blocks'][node]['address'], type: displayedNode['blocks'][node]['type'], color: '#FFB703', handleExpandClick: handleExploreClick }, sourcePosition: Position.Bottom, targetPosition: Position.Right, position: position }); + } + } + // Assuming fetchedNodes is an array of nodes in the format expected by React Flow + setNodes(populatedNodes); + const populatedEdges = []; + selected_links_data = {}; + for (const edge_id in displayedNode['harnesses']) { + const edge = displayedNode['harnesses'][edge_id]; + + // for each edge_id, update the data structure with the list of links on that harness + selected_links_data[edge_id] = {source: edge['source'], target: edge['target'], links: edge['links']}; + + // create a react edge element for each harness + populatedEdges.push({ + id: edge_id, + source: edge['source'], + target: edge['target'], + type: 'custom', + sourcePosition: Position.Right, + targetPosition: Position.Left, + markerEnd: { + type: MarkerType.Arrow, + }, + data: { + source: edge['source'], + target: edge['target'], + name: edge['name'], + preview_names: edge['preview_names'], + } + }); + } + setEdges(populatedEdges); + + } catch (error) { + console.error("Failed to fetch nodes:", error); + } + }; + + updateNodesFromJson(); + }, [viewBlockId]); + + const onSelectionChange = (elements) => { + // Filter out the selected edges from the selection + const selectedEdge = elements['edges'][0]; + // check if there is a selected edge + if (selectedEdge && !requestRelayout) { + setSelectedLinkId(selectedEdge.id); + setSelectedLinkData(selected_links_data[selectedEdge.id]['links']); + setSelectedLinkSource(selected_links_data[selectedEdge.id]['source']); + setSelectedLinkTarget(selected_links_data[selectedEdge.id]['target']); + } else if (selected_link_id != "none") { + setSelectedLinkId("none"); + setSelectedLinkData([]); + setSelectedLinkSource("none"); + setSelectedLinkTarget("none"); + } + }; + + return ( +
+ + + + + + + + +
+ ); +}; + +export default AtopileBlockDiagramApp; diff --git a/src/atopile/viewer/src/SchematicApp.tsx b/src/atopile/viewer/src/SchematicApp.tsx new file mode 100644 index 00000000..0a579314 --- /dev/null +++ b/src/atopile/viewer/src/SchematicApp.tsx @@ -0,0 +1,262 @@ +// @ts-nocheck +import React, { useCallback, useEffect, useLayoutEffect, useState } from 'react'; +import ReactFlow, { + addEdge, + Background, + useNodesState, + useEdgesState, + MarkerType, + useReactFlow, + ReactFlowProvider, + Panel, + Position, + isEdge, + Edge, + useStore, + useKeyPress, + useUpdateNodeInternals, + applyNodeChanges +} from 'reactflow'; +import 'reactflow/dist/style.css'; + +import SimpleTable from './LinkTable.tsx'; + +import './index.css'; + +import "react-data-grid/lib/styles.css"; +import { SchematicComponent, SchematicSignal, SchematicScatter } from './components/SchematicElements.tsx'; + + +const nodeTypes = { + SchematicComponent: SchematicComponent, + SchematicSignal: SchematicSignal, + SchematicScatter: SchematicScatter +}; + +const edgeTypes = {}; + +async function loadSchematicJsonAsDict() { + const response = await fetch('http://127.0.0.1:8080/schematic'); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + return response.json(); +} + +let request_ratsnest_update = false; +let nets = []; +let nets_distance = []; +let port_to_component_map = {}; +let component_positions = {}; + + +const AtopileSchematicApp = ({ viewBlockId, savePos, reload }) => { + const [nodes, setNodes, onNodesChange] = useNodesState([]); + const [edges, setEdges, onEdgesChange] = useEdgesState([]); + const { fitView } = useReactFlow(); + const [loading, setLoading] = useState(true); + const [tooLarge, setTooLarge] = useState(false); + + const rotateAction = useKeyPress(['r', 'R']); + const mirrorAction = useKeyPress(['f', 'F']); + + + useEffect(() => { + const updateNodesFromJson = async () => { + try { + const fetchedNodes = await loadSchematicJsonAsDict(); + const displayedNode = fetchedNodes[viewBlockId]; + //handleBlockLoad("root"); + if (Object.keys(displayedNode['components']).length > 50) { + setTooLarge(true); + return; + } + + const populatedNodes = []; + let index = 0; + for (const [component_name, component_data] of Object.entries(displayedNode['components'])) { + let position = { + x: 100, + y: 50 * index, + }; + index++; + if (component_data['std_lib_id'] !== "") { + if (component_name in component_positions) { + position = component_positions[component_name]; + } + populatedNodes.push({ id: component_name, type: "SchematicComponent", data: component_data, position: position }); + for (const port in component_data['ports']) { + port_to_component_map[component_data['ports'][port]['net_id']] = component_name; + } + } else { + Object.entries(component_data['ports']).forEach(([port_id, port_data], index) => { + if (port_data['net_id'] in component_positions) { + position = component_positions[port_data['net_id']]; + } + populatedNodes.push({ + id: port_data['net_id'], + type: "SchematicScatter", + data: { id: port_data['net_id'], name: port_data['name'] }, + position: position + }); + port_to_component_map[port_data['net_id']] = port_data['net_id']; + }); + } + } + // for (const [signal_name, signal_data] of Object.entries(displayedNode['signals'])) { + // const position = { + // x: Math.random() * window.innerWidth, + // y: Math.random() * window.innerHeight, + // }; + // if (signal_data['std_lib_id'] !== "") { + // populatedNodes.push({ id: signal_name, type: "SchematicSignal", data: signal_data, position: position }); + // port_to_component_map[signal_name] = signal_name; + // } + // } + // Assuming displayedNode is an array of nodes in the format expected by React Flow + setNodes(populatedNodes); + + nets = displayedNode['nets']; + + } catch (error) { + console.error("Failed to fetch nodes:", error); + } + }; + + updateNodesFromJson(); + setLoading(false); + }, [viewBlockId, reload]); + + useEffect(() => { + let updatedNodes = []; + updatedNodes = nodes.map((node) => { + if (node.selected) { + return { + ...node, + data: { + ...node.data, + rotation: rotateAction? (node.data.rotation + 90) % 360 : node.data.rotation, + mirror: mirrorAction? !node.data.mirror : node.data.mirror, + } + }; + } + return node; + }); + setNodes(updatedNodes); + }, [rotateAction, mirrorAction]); + + + const onSelectionChange = (elements) => { + if (request_ratsnest_update && !loading) { + request_ratsnest_update = false; + addLinks(); + return; + } + request_ratsnest_update = true; + }; + + function addLinks() { + try { + // Add the shortest links to complete all the nets + // Get all the component positions + component_positions = {}; + for (const node of nodes) { + component_positions[node.id] = node.position; + } + // for each component in the net, calculate the distance to the other components in the net + let nets_distances = []; + for (const net of nets) { + let net_distances = {}; + for (const conn_id of net) { + let conn_to_conn_distance = {}; + for (const other_conn_id of net) { + if (conn_id != other_conn_id && conn_id in port_to_component_map && other_conn_id in port_to_component_map) { + const conn_pos = component_positions[port_to_component_map[conn_id]]; + const other_conn_pos = component_positions[port_to_component_map[other_conn_id]]; + conn_to_conn_distance[other_conn_id] = Math.sqrt(Math.pow(conn_pos.x - other_conn_pos.x, 2) + Math.pow(conn_pos.y - other_conn_pos.y, 2)); + } + } + net_distances[conn_id] = conn_to_conn_distance; + } + nets_distances.push(net_distances); + } + + // nearest neighbor algorithm https://en.wikipedia.org/wiki/Nearest_neighbour_algorithm + let conn_visited = {}; + for (const net of nets) { + for (const conn_id of net) { + conn_visited[conn_id] = false; + } + } + + let links_to_add = {}; + for (const net of nets_distances) { + for (const conn_id in net) { + if (conn_visited[conn_id]) { + continue; + } + let closest_conn_id = "none"; + let closest_conn_distance = Infinity; + for (const other_conn_id in net) { + if (conn_id == other_conn_id) { + continue; + } + if (net[conn_id][other_conn_id] < closest_conn_distance && conn_visited[other_conn_id] === false) { + closest_conn_id = other_conn_id; + closest_conn_distance = net[conn_id][other_conn_id]; + } + } + conn_visited[conn_id] = true; + links_to_add[conn_id] = closest_conn_id; + } + } + + const populatedEdges = []; + for (const edge in links_to_add) { + populatedEdges.push({ + id: edge + links_to_add[edge], + source: port_to_component_map[edge], + sourceHandle: edge, + target: port_to_component_map[links_to_add[edge]], + targetHandle: links_to_add[edge], + type: 'step', + style: { + stroke: 'black', + strokeWidth: 2, + }, + }); + } + setEdges(populatedEdges); + } catch (error) { + console.error("Failed to add links:", error); + } + } + + return ( +
+ {tooLarge ? ( +
+ There are more than 20 components to display. Navigate to a different module. +
+ ) : ( + + + + + )} +
+ ); +}; + +export default AtopileSchematicApp; diff --git a/src/atopile/viewer/src/SchematicElements.tsx b/src/atopile/viewer/src/SchematicElements.tsx new file mode 100644 index 00000000..2cf39c72 --- /dev/null +++ b/src/atopile/viewer/src/SchematicElements.tsx @@ -0,0 +1,394 @@ +// @ts-nocheck +import React, { useCallback, useEffect, useLayoutEffect, useState } from 'react'; +import { Handle, Position, NodeProps, NodeToolbar } from 'reactflow'; + +const TwoPinHandle = ({port_1, port_2, orientation}) => { + // Determine the orientation based on the provided prop or a default value + const currentOrientation = orientation || "horizontal"; + + return ( + <> + {/* Handles for port_1 */} + + + + {/* Handles for port_2 */} + + + + ) +}; + +const NameAndValue = ({name, value}) => { + return ( +
+
{name}
+
{value}
+
+ ) +} + +export const Resistor = ( { data }: {data: NodeProps} ) => { + // From: https://github.com/chris-pikul/electronic-symbols/tree/main + return ( + <> + +
+ + ; + +
+ + ) +}; + +export const Capacitor = ( { data }: {data: NodeProps} ) => { + // From: https://github.com/chris-pikul/electronic-symbols/tree/main + return ( + <> + +
+ + + +
+ + ) +}; + +export const LED = ( { data }: {data: NodeProps} ) => { + // From: https://github.com/chris-pikul/electronic-symbols/tree/main + return ( + <> + +
+ + + + + + +
+ + ) +}; +export const NFET = ( { data }: {data: NodeProps} ) => { + // From: https://github.com/chris-pikul/electronic-symbols/tree/main + return ( + <> + +
+ + + + + + +
+ + ) +}; +export const PFET = ( { data }: {data: NodeProps} ) => { + // From: https://github.com/chris-pikul/electronic-symbols/tree/main + return ( +
+ +
+ + + + + + + +
+
+ ) +}; +export const Diode = ( { data }: {data: NodeProps} ) => { + // From: https://github.com/chris-pikul/electronic-symbols/tree/main + return ( +
+ +
+ + + +
+
+ ) +}; +export const ZenerDiode = ( { data }: {data: NodeProps} ) => { + // From: https://github.com/chris-pikul/electronic-symbols/tree/main + return ( +
+ +
+ + + + + + +
+
+ ) +}; +export const SchottkyDiode = ( { data }: {data: NodeProps} ) => { + // From: https://github.com/chris-pikul/electronic-symbols/tree/main + return ( +
+ +
+ + + + + + +
+
+ ) +}; + +export const Ground = ( { data }: {data: NodeProps} ) => { + // From: https://github.com/chris-pikul/electronic-symbols/tree/main + return ( + <> + + +
+ + + +
+ + ) +}; + +export const Vcc = ( { data }: {data: NodeProps} ) => { + // From: https://github.com/chris-pikul/electronic-symbols/tree/main + return ( + <> + + +
+ + + +
+ + ) +}; + + +export const Signal = ( { data }: {data: NodeProps} ) => { + // From: https://github.com/chris-pikul/electronic-symbols/tree/main + return ( +
+ + +
{data.name}
+
+ ) +}; + +export const OpAmp = ( { data }: {data: NodeProps} ) => { + const port_ids = Object.keys(data.ports); + // From: https://github.com/chris-pikul/electronic-symbols/tree/main + return ( + <> + + + + + + + + + + +
+ + + +
+ + ) +}; + + +export const NPN = ( { data }: {data: NodeProps} ) => { + const port_ids = Object.keys(data.ports); + // From: https://github.com/chris-pikul/electronic-symbols/tree/main + return ( +
+ + + + + + +
+ + + + + +
+
+ ) +}; + +export const Bug = ({ data }: {data: NodeProps}) => { + + const LeftPins = Object.entries(data.ports).map(([key, value], index) => { + console.log("key") + console.log(key) + return ( + + +
+ {value} +
+
) + }); + + return ( + <> +
+
+ {LeftPins} +
+
+
+
{data.instance_of}
+
{data.name}
+
+
+
+ + ) +}; \ No newline at end of file diff --git a/src/atopile/viewer/src/components/SchematicElements.tsx b/src/atopile/viewer/src/components/SchematicElements.tsx new file mode 100644 index 00000000..6511c673 --- /dev/null +++ b/src/atopile/viewer/src/components/SchematicElements.tsx @@ -0,0 +1,632 @@ +//@ts-nocheck +import React, { useState, useEffect} from 'react'; +import {Handle, useUpdateNodeInternals, Position} from 'reactflow'; + + +// Utility to determine new position after rotation +function rotatePosition(initialPosition, degrees) { + const rotations = { + [Position.Top]: Position.Right, + [Position.Right]: Position.Bottom, + [Position.Bottom]: Position.Left, + [Position.Left]: Position.Top + }; + + const steps = (degrees / 90) % 4; + let newPosition = initialPosition; + for (let i = 0; i < steps; i++) { + newPosition = rotations[newPosition]; + } + return newPosition; + } + +// Utility to apply mirror transformation along the X-axis +function mirrorPosition(position, mirror) { + if (!mirror) return position; + const mirrors = { + [Position.Left]: Position.Right, + [Position.Right]: Position.Left, + [Position.Top]: Position.Top, + [Position.Bottom]: Position.Bottom + }; + + return mirrors[position] || position; + } + +const MultiPinHandle = ({ ports, rotationDegrees = 0, mirrorX = false}) => { + //TODO: remove this + const portsArray = Array.isArray(ports) ? ports : Object.values(ports); + + if (!portsArray.length) { + console.error('Ports data is invalid or empty:', portsArray); + return null; + } + + // Function to calculate the final position for each port + const calculateRotation = (initialPosition) => { + let position = mirrorPosition(initialPosition, mirrorX); + position = rotatePosition(position, rotationDegrees); // Default to 'left' if initialPosition is undefined + return position; + }; + + const calculateOffset = (port, mirrorX, rotationDegrees) => { + // Adjust rotation based on mirrorX condition + const effectiveRotation = mirrorX ? rotationDegrees + 180 : rotationDegrees; + + // Calculate the effective offset direction and value + const direction = port.offset_dir[effectiveRotation % 360]; + const offsetValue = port.offset[effectiveRotation % 360]; + + // Create the style object dynamically + const style = { [direction]: `${offsetValue}px` }; + + return style; + }; + return ( + <> + {portsArray.map((port) => ( + + + + + ))} + + ); +}; + + +// Common function to handle and render electronic components +export const SchematicComponent = ({ id, data }) => { + const [rotation, setRotation] = useState(0); + const [mirror, setMirror] = useState(false); + const [position, setCompPosition] = useState({ x: 0, y: 0 }); + const updateNodeInternals = useUpdateNodeInternals(); + + useEffect(() => { + setRotation(data.rotation); + //TODO: add mirroring for more complex components + //setMirror(data.mirror); + setCompPosition(data.position); + updateNodeInternals(id); + }, [data]); + + const transform = `rotate(${rotation}deg) ${mirror ? 'scaleX(-1)' : ''}`; + + // Get the data for each component type + const component_metadata = getComponentMetaData(data.std_lib_id); + let populated_ports = component_metadata.ports; + + let index = 0; + for (const [key, value] of Object.entries(populated_ports)) { + if (populated_ports[key] === undefined || data.ports[index] === undefined) { + throw new Error(`Port ${key} not found in component metadata. Did you update your generics library?`); + } + populated_ports[key].id = data.ports[index].net_id; + populated_ports[key].name = data.ports[index].name; + index++; + } + + return ( + <> + +
+ + +
{data.name}
+
+ + ); +}; + +// Common function to handle and render electronic signal +export const SchematicSignal = ({ id, data }) => { + const [position, setCompPosition] = useState({ x: 0, y: 0 }); + const updateNodeInternals = useUpdateNodeInternals(); + + useEffect(() => { + setCompPosition(data.position); + updateNodeInternals(id); + }, [data]); + + // Get the data for each component type + const component_metadata = getComponentMetaData(data.std_lib_id); + let populated_ports = component_metadata.ports; + + let index = 0; + for (const [key, value] of Object.entries(populated_ports)) { + populated_ports[key].id = data.address; + populated_ports[key].name = data.name; + index++; + } + + return ( + <> + +
+ + +
{data.name}
+
+ + ); +}; + +// Common function to handle and render electronic pins scattered around the place +export const SchematicScatter = ({ id, data }) => { + const [position, setCompPosition] = useState({ x: 0, y: 0 }); + const [mirror, setMirror] = useState(false); + const updateNodeInternals = useUpdateNodeInternals(); + + useEffect(() => { + setCompPosition(data.position); + setMirror(data.mirror); + updateNodeInternals(id); + }, [data]); + + return ( + <> + +
+ {data.name} +
+ + + ); +}; + +function getComponentMetaData(component) { + //TODO: move the std lib back into the compiler? + // components courtesy of: https://github.com/chris-pikul/electronic-symbols + + //TODO: we will have to break this up into multiple different files. Keeping this here for the second. + const component_metadata = { + "Resistor": { + "ports": { + "p1": { + initialPosition: Position.Left, + offset: 35, + offset_dir: Position.Top + }, + "p2": { + initialPosition: Position.Right, + offset: 25, + offset_dir: Position.Top + } + }, + "svg": `` + }, + "Inductor": { + "ports": { + "p1": { + initialPosition: Position.Left, + offset: 35, + offset_dir: Position.Top + }, + "p2": { + initialPosition: Position.Right, + offset: 25, + offset_dir: Position.Top + } + }, + "svg": `` + }, + "ZenerDiode": { + "ports": { + "anode": { + initialPosition: Position.Left, + offset: 35, + offset_dir: Position.Top + }, + "cathode": { + initialPosition: Position.Right, + offset: 25, + offset_dir: Position.Top + } + }, + "svg": ` + + + ` + }, + "SchottkyDiode": { + "ports": { + "anode": { + initialPosition: Position.Left, + offset: 35, + offset_dir: Position.Top + }, + "cathode": { + initialPosition: Position.Right, + offset: 25, + offset_dir: Position.Top + } + }, + "svg": ` + + + ` + }, + "Diode": { + "ports": { + "anode": { + initialPosition: Position.Left, + offset: 25, + offset_dir: Position.Top + }, + "cathode": { + initialPosition: Position.Right, + offset: 25, + offset_dir: Position.Top + } + }, + "svg": `` + }, + "LED": { + "ports": { + "anode": { + initialPosition: Position.Left, + offset: 25, + offset_dir: Position.Top + }, + "cathode": { + initialPosition: Position.Right, + offset: 25, + offset_dir: Position.Top + } + }, + "svg": ` + + + ` + }, + "Capacitor": { + "ports": { + "p1": { + initialPosition: Position.Left, + offset: 35, + offset_dir: Position.Top + }, + "p2": { + initialPosition: Position.Right, + offset: 25, + offset_dir: Position.Top + } + }, + "svg": `` + + }, + "NFET": { + "ports": { + "gate": { + initialPosition: Position.Left, + offset: { + 0: 25, + 90: 25, + 180: 25, + 270: 25 + }, + offset_dir: { + 0: Position.Top, + 90: Position.Left, + 180: Position.Top, + 270: Position.Left + } + }, + "drain": { + initialPosition: Position.Bottom, + offset: { + 0: 33, + 90: 33, + 180: 17, + 270: 17 + }, + offset_dir: { + 0: Position.Left, + 90: Position.Top, + 180: Position.Left, + 270: Position.Top + } + }, + "source": { + initialPosition: Position.Top, + offset: { + 0: 33, + 90: 33, + 180: 17, + 270: 17 + }, + offset_dir: { + 0: Position.Left, + 90: Position.Top, + 180: Position.Left, + 270: Position.Top + } + } + }, + "svg": ` + + + ` + }, + "PFET": { + "ports": { + "gate": { + initialPosition: Position.Left, + offset: { + 0: 25, + 90: 25, + 180: 25, + 270: 25 + }, + offset_dir: { + 0: Position.Top, + 90: Position.Left, + 180: Position.Top, + 270: Position.Left + } + }, + "source": { + initialPosition: Position.Top, + offset: { + 0: 33, + 90: 33, + 180: 17, + 270: 17 + }, + offset_dir: { + 0: Position.Left, + 90: Position.Top, + 180: Position.Left, + 270: Position.Top + } + }, + "drain": { + initialPosition: Position.Bottom, + offset: { + 0: 33, + 90: 33, + 180: 17, + 270: 17 + }, + offset_dir: { + 0: Position.Left, + 90: Position.Top, + 180: Position.Left, + 270: Position.Top + } + }, + }, + "svg": ` + + + + ` + }, + "PNP": { + "ports": { + "gate": { + initialPosition: Position.Left, + offset: { + 0: 25, + 90: 25, + 180: 25, + 270: 25 + }, + offset_dir: { + 0: Position.Top, + 90: Position.Left, + 180: Position.Top, + 270: Position.Left + } + }, + "drain": { + initialPosition: Position.Bottom, + offset: { + 0: 33, + 90: 33, + 180: 17, + 270: 17 + }, + offset_dir: { + 0: Position.Left, + 90: Position.Top, + 180: Position.Left, + 270: Position.Top + } + }, + "source": { + initialPosition: Position.Top, + offset: { + 0: 33, + 90: 33, + 180: 17, + 270: 17 + }, + offset_dir: { + 0: Position.Left, + 90: Position.Top, + 180: Position.Left, + 270: Position.Top + } + } + }, + "svg": ` + + ` + }, + "NPN": { + "ports": { + "gate": { + initialPosition: Position.Left, + offset: { + 0: 25, + 90: 25, + 180: 25, + 270: 25 + }, + offset_dir: { + 0: Position.Top, + 90: Position.Left, + 180: Position.Top, + 270: Position.Left + } + }, + "drain": { + initialPosition: Position.Bottom, + offset: { + 0: 33, + 90: 33, + 180: 17, + 270: 17 + }, + offset_dir: { + 0: Position.Left, + 90: Position.Top, + 180: Position.Left, + 270: Position.Top + } + }, + "source": { + initialPosition: Position.Top, + offset: { + 0: 33, + 90: 33, + 180: 17, + 270: 17 + }, + offset_dir: { + 0: Position.Left, + 90: Position.Top, + 180: Position.Left, + 270: Position.Top + } + } + }, + "svg": ` + + ` + }, + "Opamp": { + "ports": { + "power.vcc": { + initialPosition: Position.Top, + offset: { + 0: 25, + 90: 25, + 180: 25, + 270: 25 + }, + offset_dir: { + 0: Position.Left, + 90: Position.Top, + 180: Position.Left, + 270: Position.Top + } + }, + "power.gnd": { + initialPosition: Position.Bottom, + offset: { + 0: 25, + 90: 25, + 180: 25, + 270: 25 + }, + offset_dir: { + 0: Position.Left, + 90: Position.Top, + 180: Position.Left, + 270: Position.Top + } + }, + "inverting": { + initialPosition: Position.Left, + offset: { + 0: 33, + 90: 17, + 180: 17, + 270: 33 + }, + offset_dir: { + 0: Position.Top, + 90: Position.Left, + 180: Position.Top, + 270: Position.Left + } + }, + "non_inverting": { + initialPosition: Position.Left, + offset: { + 0: 17, + 90: 33, + 180: 33, + 270: 17 + }, + offset_dir: { + 0: Position.Top, + 90: Position.Left, + 180: Position.Top, + 270: Position.Left + } + }, + "output": { + initialPosition: Position.Right, + offset: { + 0: 25, + 90: 25, + 180: 25, + 270: 25 + }, + offset_dir: { + 0: Position.Top, + 90: Position.Left, + 180: Position.Top, + 270: Position.Left + } + } + }, + "svg": `` + }, + "Power.vcc": { + "ports": { + "Power.vcc": { + initialPosition: Position.Bottom, + offset: 35, + offset_dir: Position.Left + } + }, + "svg": ` + + ` + }, + "Power.gnd": { + "ports": { + "Power.gnd": { + initialPosition: Position.Top, + offset: 35, + offset_dir: Position.Left + } + }, + "svg": `` + } + }; + + return component_metadata[component]; +} \ No newline at end of file diff --git a/src/atopile/viewer/src/components/diodes.tsx b/src/atopile/viewer/src/components/diodes.tsx new file mode 100644 index 00000000..eab3f429 --- /dev/null +++ b/src/atopile/viewer/src/components/diodes.tsx @@ -0,0 +1,143 @@ +//@ts-nocheck +import React, { useCallback, useEffect, useLayoutEffect, useState } from 'react'; +import { SchematicElectronicComponent } from './SchematicElements'; +import { Handle, useUpdateNodeInternals, Position } from 'reactflow'; + + +const DiodeComponent = ({ id, data, svgContent}) => { + const [rotation, setRotation] = useState(0); + const [mirror, setMirror] = useState(false); + const [position, setCompPosition] = useState({ x: 0, y: 0 }); + const updateNodeInternals = useUpdateNodeInternals(); + const adjustedPorts = [ + { + id: data.ports[0].net_id, + initialPosition: Position.LEFT, + name: data.ports[0].name, + offset: 35, + offset_dir: Position.TOP + }, + { + id: data.ports[1].net_id, + initialPosition: Position.RIGHT, + name: data.ports[1].name, + offset: 25, + offset_dir: Position.TOP + } + ]; + + return RenderElectronicComponent({ ...data, ports: adjustedPorts }, svgContent); + }; + + +// LED +const LEDSvg = ( + + + + + + +); + +export const LED = ({ data }) => { + return ; +}; + +// Shottky Diode +const ShottkyDiodeSvg = ( + + + + + + +); + +export const ZenerDiode = ({ id, data }) => { + const [rotation, setRotation] = useState(0); + const [mirror, setMirror] = useState(false); + const [position, setCompPosition] = useState({ x: 0, y: 0 }); + const updateNodeInternals = useUpdateNodeInternals(); + // const adjustedPorts = [ + // { + // id: data.ports[0].net_id, + // initialPosition: Position.LEFT, + // name: data.ports[0].name, + // offset: 35, + // offset_dir: Position.TOP + // }, + // { + // id: data.ports[1].net_id, + // initialPosition: Position.RIGHT, + // name: data.ports[1].name, + // offset: 25, + // offset_dir: Position.TOP + // } + // ]; + + // return RenderElectronicComponent({ ...data, ports: adjustedPorts }, svgContent); + useEffect(() => { + setRotation(data.rotation); + setMirror(data.mirror); + setCompPosition(data.position); + updateNodeInternals(id); + }, [data]); // Only re-run if `data` changes + + function handleRotation() { + setRotation(rotation + 1); + console.log(rotation); + console.log(id); + updateNodeInternals(id); + } + return ( + <> + + +
+ + + + + + +
+ + + ); + }; + +// Zenner Diode +// const ZenerDiodeSvg = ( +// +// +// +// +// +// +// ); + +// export const ZenerDiode = ({ data }) => { +// return ; +// } + +// Diode +const DiodeSvg = ( + + + +); + +export const Diode = ({ data }) => { + return ; +} diff --git a/src/atopile/viewer/src/components/power.tsx b/src/atopile/viewer/src/components/power.tsx new file mode 100644 index 00000000..012fc33c --- /dev/null +++ b/src/atopile/viewer/src/components/power.tsx @@ -0,0 +1,38 @@ +//@ts-nocheck +import {RenderElectronicComponent } from './SchematicElements'; + + +const PowerComponent = ({ data, svgContent }) => { + // Adjust the ports data to include static mapping of anode and cathode + + return RenderElectronicComponent(data, svgContent); + }; + + +// VCC +const VCCSvg = ( + + + + + + +); + +export const VCC = ({ data }) => { + return ; +}; + +// GND +const GNDSvg = ( + + + + + + +); + +export const GND = ({ data }) => { + return ; +}; \ No newline at end of file diff --git a/src/atopile/viewer/src/index.css b/src/atopile/viewer/src/index.css index c7d05918..0b53afdd 100644 --- a/src/atopile/viewer/src/index.css +++ b/src/atopile/viewer/src/index.css @@ -1,19 +1,4 @@ -.floatingedges { - flex-direction: column; - display: flex; - flex-grow: 1; - height: 100%; -} -.floatingedges .react-flow__handle { - opacity: 0; -} -.reactflow-container { - width: 100%; /* or any specific width */ - height: 500px; /* or any specific height */ - /* Ensure the container is not hidden */ - display: block; -} html, body, #root { width: 100%; @@ -22,4 +7,57 @@ html, body, #root { padding: 0; box-sizing: border-box; font-family: sans-serif; -} \ No newline at end of file +} + +.floatingedges .react-flow__handle { + opacity: 1; + background-color: #FFF; + border: 1px solid black; +} + +.providerflow { + flex-direction: column; + display: flex; + flex-grow: 1; + height: 100%; +} + +.providerflow aside { + border-left: 1px solid #eee; + padding: 15px 10px; + font-size: 12px; + background: #fff; +} + +.providerflow aside .description { + margin-bottom: 10px; +} + +.providerflow aside .title { + font-weight: 700; + margin-bottom: 5px; +} + +.providerflow aside .transform { + margin-bottom: 20px; +} + +.providerflow .reactflow-wrapper { + flex-grow: 1; +} + +.providerflow .selectall { + margin-top: 10px; +} + +@media screen and (min-width: 768px) { + .providerflow { + flex-direction: row; + } + + .providerflow aside { + width: 20%; + max-width: 250px; + height: 200px; + } +} diff --git a/src/atopile/viewer/src/main.tsx b/src/atopile/viewer/src/main.tsx index 121dc0d7..d94ac306 100644 --- a/src/atopile/viewer/src/main.tsx +++ b/src/atopile/viewer/src/main.tsx @@ -1,12 +1,14 @@ -import React from 'react'; -import ReactDOM from 'react-dom/client'; +// @ts-nocheck +import React, { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import "./index.css"; -import App from './App'; +import App from "./App"; +// import OtherProviderFlow from "./AppOther"; -import './index.css'; - -ReactDOM.createRoot(document.getElementById('root')!).render( - +const root = createRoot(document.getElementById("root")); +root.render( + - + ); diff --git a/src/atopile/viewer_utils.py b/src/atopile/viewer_utils.py index 7f9b9434..437d56ce 100644 --- a/src/atopile/viewer_utils.py +++ b/src/atopile/viewer_utils.py @@ -1,4 +1,4 @@ -from atopile.address import AddrStr, get_parent_instance_addr, get_name, get_instance_section, get_entry_section +from atopile.address import AddrStr, get_parent_instance_addr, get_name, get_instance_section, get_relative_addr_str from atopile.instance_methods import ( get_children, get_links, @@ -9,25 +9,35 @@ match_interfaces, match_pins_and_signals, ) -from atopile.components import get_specd_value +import atopile.config -import json import networkx as nx from collections import defaultdict from typing import DefaultDict, Tuple -def get_parent(addr: AddrStr, root) -> AddrStr: +def get_parent(addr: AddrStr, build_ctx: atopile.config.BuildContext) -> AddrStr: """ returns the parent of the given address or root if there is none """ - if addr == root: + if addr == build_ctx.entry: return "null" + elif get_parent_instance_addr(addr) == build_ctx.entry: + return "root" - return get_instance_section(get_parent_instance_addr(addr)) or "root" + return get_relative_addr_str(get_parent_instance_addr(addr), build_ctx.project_context.project_path) -def get_blocks(addr: AddrStr) -> dict[str, dict[str, str]]: +def get_id(addr: AddrStr, build_ctx: atopile.config.BuildContext) -> AddrStr: + """ + returns the parent of the given address or root if there is none + """ + if addr == build_ctx.entry: + return "root" + + return get_relative_addr_str(addr, build_ctx.project_context.project_path) + +def get_blocks(addr, build_ctx: atopile.config.BuildContext) -> dict[str, dict[str, str]]: """ returns a dictionary of blocks: { @@ -42,25 +52,20 @@ def get_blocks(addr: AddrStr) -> dict[str, dict[str, str]]: block_dict = {} for child in get_children(addr): if match_modules(child) or match_components(child) or match_interfaces(child) or match_pins_and_signals(child): - value = "none" type = "module" - lib_key = "none" if match_components(child): type = "component" - if _is_builtin(child): - value = get_specd_value(child) - type = "builtin" - lib_key = _is_builtin(child) elif match_interfaces(child): type = "interface" elif match_pins_and_signals(child): type = "signal" + else: + type = "module" + block_dict[get_name(child)] = { "instance_of": get_name(get_supers_list(child)[0].obj_def.address), "type": type, - "lib_key": lib_key, - "address": get_instance_section(child), - "value": value} + "address": get_relative_addr_str(child, build_ctx.project_context.project_path)} return block_dict @@ -230,17 +235,17 @@ def get_harnesses(addr: AddrStr) -> list[dict]: return harness_return_dict -def get_vis_dict(root: AddrStr) -> str: +def get_vis_dict(build_ctx: atopile.config.BuildContext) -> dict: return_json = {} - - for addr in all_descendants(root): + # for addr in chain(root, all_descendants(root)): + for addr in all_descendants(build_ctx.entry): block_dict = {} link_list = [] # we only create an entry for modules, not for components if match_modules(addr) and not match_components(addr): - instance = get_instance_section(addr) or "root" - parent = get_parent(addr, root) - block_dict = get_blocks(addr) + instance = get_id(addr, build_ctx) + parent = get_parent(addr, build_ctx) + block_dict = get_blocks(addr, build_ctx) link_list = process_links(addr) harness_dict = get_harnesses(addr) @@ -251,7 +256,7 @@ def get_vis_dict(root: AddrStr) -> str: "harnesses": harness_dict, } - return json.dumps(return_json) + return return_json def get_current_depth(addr: AddrStr) -> int: instance_section = get_instance_section(addr) @@ -272,15 +277,3 @@ def split_list_at_n(n, list_of_strings): second_part = list_of_strings[n+1:] return first_part, second_part - -def _is_builtin(addr: AddrStr) -> bool|str: - """ - Check if the given address is a builtin component, if so, return the builtin type (Resistor, Capacitor, etc.) - """ - _supers_list = get_supers_list(addr) - for duper in _supers_list: - if get_entry_section(duper.address) == "Resistor": - return "Resistor" - elif get_entry_section(duper.address) == "Capacitor": - return "Capacitor" - return False diff --git a/tests/test_front_end/prj/ato.yaml b/tests/test_front_end/prj/ato.yaml new file mode 100644 index 00000000..98daa615 --- /dev/null +++ b/tests/test_front_end/prj/ato.yaml @@ -0,0 +1,7 @@ +ato-version: ^0.2.0 + +builds: + default: + entry: test.ato:Test + +dependencies: [] diff --git a/tests/test_front_end/prj/test.ato b/tests/test_front_end/prj/test.ato new file mode 100644 index 00000000..7dcdeb10 --- /dev/null +++ b/tests/test_front_end/prj/test.ato @@ -0,0 +1,14 @@ +component Comp: + footprint = "" + mpn = "" + pin 1 + pin 2 + +module Test: + c1 = new Comp + c2 = new Comp + c3 = new Comp + + c1.2 ~ c2.1 + c1.1 ~ c2.2 + c1.2 ~ c3.1 diff --git a/tests/test_front_end/test_caching.py b/tests/test_front_end/test_caching.py new file mode 100644 index 00000000..9ec1f76a --- /dev/null +++ b/tests/test_front_end/test_caching.py @@ -0,0 +1,36 @@ +import textwrap +from pathlib import Path + +from atopile import front_end, parse +from atopile.front_end import parser + +PRJ = Path(__file__).parent / "prj" +FILE = PRJ / "test.ato" +MODULE = str(FILE) + ":Test" + + +def test_caching(): + parser.cache[str(FILE)] = parse.parse_text_as_file( + textwrap.dedent( + """ + module Test: + a = 1 + b = 2 + """ + ), + MODULE, + ) + root_1 = front_end.lofty.get_instance(MODULE) + + # Make sure it's parsed properly + assert root_1.assignments["a"][0].value == 1 + + # Make sure it's consistent and cached + assert root_1 is front_end.lofty.get_instance(MODULE) + + # Clear it out, to make sure it's re-parsed + front_end.reset_caches(MODULE) + + # Parse something else to make sure it's re-parsed + root_2 = front_end.lofty.get_instance(MODULE) + assert root_1 is not root_2