diff --git a/CHANGELOG.md b/CHANGELOG.md index c800450f..a7e2d61d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Internal templates import - Better support for wrong pre-flight options (#360) - A mechanism to cache downloaded 3D models + - Support to download 3D models from EasyEDA (using LCSC codes) - Global options: - field_lcsc_part: to select the LCSC/JLCPCB part field - New outputs: diff --git a/README.md b/README.md index bda8a3d5..2d0af69c 100644 --- a/README.md +++ b/README.md @@ -5928,6 +5928,12 @@ I strongly suggest including all used 3D models in your repo. You can then use `${KIPRJMOD}` as base for the path to the models, this will be expanded to the current path to your project. So you can use things like `${KIPRJMOD}/3D/MODEL_NAME` and store all the 3D models in the *3D* folder inside your project folder. +### LCSC/JLCPCB/EasyEDA 3D models + +KiBot can download 3D models for components that has an LCSC code and that has a 3D model at [EasyEDA](https://easyeda.com/). +If the 3D model is used locally, but not found in the repo, KiBot will try to download it. +Use the `field_lcsc_part` option if KiBot fails to detect the schematic field containing the LCSC code. + ### 3D models aliases This is a very limited feature in KiCad. You can define an `ALIAS` and then use `ALIAS:MODEL_NAME`. diff --git a/docs/README.in b/docs/README.in index 4d8a0d93..31b320df 100644 --- a/docs/README.in +++ b/docs/README.in @@ -2059,6 +2059,12 @@ I strongly suggest including all used 3D models in your repo. You can then use `${KIPRJMOD}` as base for the path to the models, this will be expanded to the current path to your project. So you can use things like `${KIPRJMOD}/3D/MODEL_NAME` and store all the 3D models in the *3D* folder inside your project folder. +### LCSC/JLCPCB/EasyEDA 3D models + +KiBot can download 3D models for components that has an LCSC code and that has a 3D model at [EasyEDA](https://easyeda.com/). +If the 3D model is used locally, but not found in the repo, KiBot will try to download it. +Use the `field_lcsc_part` option if KiBot fails to detect the schematic field containing the LCSC code. + ### 3D models aliases This is a very limited feature in KiCad. You can define an `ALIAS` and then use `ALIAS:MODEL_NAME`. diff --git a/experiments/EasyEDA/README.md b/experiments/EasyEDA/README.md index 08bfd288..cecd0744 100644 --- a/experiments/EasyEDA/README.md +++ b/experiments/EasyEDA/README.md @@ -8,3 +8,97 @@ This is the portion of the code needed to download the 3D model for an LCSC comp The generated WRL has colors, but not correct materials. This is all related to the issue #380 + +# Colors + +diffuse - specular + +## Analyzed + +### SOT-23-3P_L2.9-W1.3-H1.0-LS2.4-P0.95.wrl (C181094) +- LCEDA: 1.0 1.0 1.0 - 0.8784313725490196 0.8784313725490196 0.8784313725490196 +- Silver pins:0.7686274509803922 0.7686274509803922 0.7686274509803922 - 0.615686274509804 0.615686274509804 0.615686274509804 +- Plastic body: 0.25098039215686274 0.25098039215686274 0.25098039215686274 - 0.07450980392156863 0.07450980392156863 0.07450980392156863 +- Text legend: 0.45098039215686275 0.43137254901960786 0.4196078431372549 - 0.03137254901960784 0.03137254901960784 0.03137254901960784 + +### CONN-TH_4P-P2.54_MOLEX_0705430004.wrl (C3020650) + +This model is wrong, the pins should be gold + +- Silver pins:(also legend) 0.8313725490196079 0.8313725490196079 0.8196078431372549 - 0.6666666666666666 0.6666666666666666 0.6549019607843137 +- Plastic body: 0.20784313725490197 0.20784313725490197 0.20784313725490197 - 0.01568627450980392 0.01568627450980392 0.01568627450980392 +- Plastic body pin 1 arrow: 0.07058823529411765 0.07058823529411765 0.07058823529411765 - 0.00392156862745098 0.00392156862745098 0.00392156862745098 +- Error color used to fill the legend ... should be plastic body, an error: 0.792156862745098 0.8196078431372549 0.9333333333333333 - 0.396078431372549 0.4117647058823529 0.4666666666666667 + +### HDR2.54-M-LI-1X1P.wrl (C213440) + +- Golden pins: 0.9490196078431372 0.7607843137254902 0.1803921568627451 - 0.7607843137254902 0.6078431372549019 0.1450980392156863 +- Plastic body: 0.17647058823529413 0.17647058823529413 0.17647058823529413 - 0.050980392156862744 0.050980392156862744 0.050980392156862744 +- LCEDA: 0.0 0.0 0.0 - 0.0 0.0 0.0 + +### SMB_L4.6-W3.6-LS5.3-BI.wrl (C78395) + +- Plastic body: 0.25098039215686274 0.25098039215686274 0.25098039215686274 - 0.12549019607843137 0.12549019607843137 0.12549019607843137 +- Silver pins: 0.7803921568627451 0.7803921568627451 0.7803921568627451 - 0.38823529411764707 0.38823529411764707 0.38823529411764707 +- LCEDA: 1.0 1.0 1.0 - 0.8784313725490196 0.8784313725490196 0.8784313725490196 + +### SMA_L4.3-W2.6-LS5.2-RD (C8678) + +- Plastic body: 0.25098039215686274 0.25098039215686274 0.25098039215686274 - 0.12549019607843137 0.12549019607843137 0.12549019607843137 +- Silver pins: 0.7803921568627451 0.7803921568627451 0.7803921568627451 - 0.38823529411764707 0.38823529411764707 0.38823529411764707 +- Text legend: 0.6666666666666666 0.6666666666666666 0.6666666666666666 - 0.3333333333333333 0.3333333333333333 0.3333333333333333 +- LCEDA 1.0 1.0 1.0 - 0.8784313725490196 0.8784313725490196 0.8784313725490196 +- Pin 1 bars 0.8509803921568627 0.8509803921568627 0.8509803921568627 - 0.4235294117647059 0.4235294117647059 0.4235294117647059 + +### SOT-23-6_L2.9-W1.6-H1.5-LS2.8-P0.95.wrl (C202311) + +- Plastic body: 0.25098039215686274 0.25098039215686274 0.25098039215686274 - 0.07450980392156863 0.07450980392156863 0.07450980392156863 +- Text legend: 0.45098039215686275 0.43137254901960786 0.4196078431372549 - 0.03137254901960784 0.03137254901960784 0.03137254901960784 +- LCEDA 1.0 1.0 1.0 - 0.8784313725490196 0.8784313725490196 0.8784313725490196 +- Silver pins: 0.7686274509803922 0.7686274509803922 0.7686274509803922 - 0.615686274509804 0.615686274509804 0.615686274509804 + +### SOIC-8_L4.9-W3.9-P1.27-LS6.0-BL-EP3.3-1.wrl (C2760005) + +- Plastic body: 0.20784313725490197 0.20784313725490197 0.20784313725490197 - 0.01568627450980392 0.01568627450980392 0.01568627450980392 +- Pin 1 circle: 0.34901960784313724 0.34901960784313724 0.34901960784313724 - 0.023529411764705882 0.023529411764705882 0.023529411764705882 +- Silver pins: 0.6666666666666666 0.6666666666666666 0.6666666666666666 - 0.4666666666666667 0.4666666666666667 0.4666666666666667 +- LCEDA: 1.0 1.0 1.0 - 0.5019607843137255 0.5019607843137255 0.5019607843137255 + +### SW-SMD_4P-L5.2-W5.2-H1.5-LS6.4-P3.70 (C318884) + +- Metal body 0.8313725490196079 0.8392156862745098 0.8313725490196079 - 0.4980392156862745 0.5019607843137255 0.4980392156862745 +- Plastic inner body 0.07058823529411765 0.07058823529411765 0.07058823529411765 - 0.00392156862745098 0.00392156862745098 0.00392156862745098 +- Golden button 0.9490196078431372 0.7607843137254902 0.1803921568627451 - 0.7607843137254902 0.6078431372549019 0.1450980392156863 +- Plastic body 0.2980392156862745 0.2980392156862745 0.2980392156862745 - 0.09019607843137255 0.09019607843137255 0.09019607843137255 +- Silver pins: 0.7686274509803922 0.7686274509803922 0.7686274509803922 - 0.615686274509804 0.615686274509804 0.615686274509804 +- LCEDA 1.0 1.0 1.0 - 0.8784313725490196 0.8784313725490196 0.8784313725490196 + +### SW-TH_SPEF110100 C115366 + +This model uses d = 1, but isn't transparent + +- Metal body and Silver pins 0.9686274509803922 0.9686274509803922 0.9686274509803922 - 0.7529411764705882 0.7529411764705882 0.7529411764705882 +- Plastic body 0.25882352941176473 0.25882352941176473 0.25882352941176473 - 0.7529411764705882 0.7529411764705882 0.7529411764705882 + +## Groups + +### Silver Metal + +- 0.969 0.969 0.969 - 0.753 0.753 0.753 +- 0.831 0.831 0.820 - 0.667 0.667 0.655 (1,56% de error en B) +- 0.780 0.780 0.780 - 0.388 0.388 0.388 (2x) +- 0.769 0.769 0.769 - 0.616 0.616 0.616 (3x) +- 0.667 0.667 0.667 - 0.467 0.467 0.467 + +### Plastic body + +- 0.298 0.298 0.298 - 0.090 0.090 0.090 +- 0.259 0.259 0.259 - 0.753 0.753 0.753 +- 0.251 0.251 0.251 - 0.125 0.125 0.125 (2x) +- 0.251 0.251 0.251 - 0.075 0.075 0.075 (2x) +- 0.208 0.208 0.208 - 0.016 0.016 0.016 (2x) +- 0.176 0.176 0.176 - 0.051 0.051 0.051 + +### Golden Metal + +- 0.949 0.761 0.180 - 0.761 0.608 0.145 (2x) diff --git a/experiments/EasyEDA/easyeda_api.py b/experiments/EasyEDA/easyeda_api.py index 832c2cba..ed446c9b 100644 --- a/experiments/EasyEDA/easyeda_api.py +++ b/experiments/EasyEDA/easyeda_api.py @@ -2,7 +2,6 @@ # License: AGPL v3 # Project: https://github.com/uPesy/easyeda2kicad.py # Global imports -from __future__ import annotations from dataclasses import dataclass import logging import os @@ -18,46 +17,41 @@ import pickle API_ENDPOINT = "https://easyeda.com/api/products/{lcsc_id}/components?version=6.4.19.5" ENDPOINT_3D_MODEL = "https://easyeda.com/analyzer/api/3dmodel/{uuid}" VRML_HEADER = "#VRML V2.0 utf8\n# 3D model generated by KiBot (using easyeda2kicad.py code)\n" -USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:102.0) Gecko/20100101 Firefox/102.0' +USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:102.0) Gecko/20100101 Firefox/102.0" class EasyedaApi: - def __init__(self) -> None: + def __init__(self): self.headers = {"Accept-Encoding": "gzip, deflate", "Accept": "application/json, text/javascript, */*; q=0.01", "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8", "User-Agent": USER_AGENT} - def get_info_from_easyeda_api(self, lcsc_id: str) -> dict: + def get_info_from_easyeda_api(self, lcsc_id: str): r = requests.get(url=API_ENDPOINT.format(lcsc_id=lcsc_id), headers=self.headers) api_response = r.json() - if not api_response or ( - "code" in api_response and api_response["success"] is False - ): + if not api_response or "code" in api_response and api_response["success"] is False: logging.debug(f"{api_response}") return {} return r.json() - def get_cad_data_of_component(self, lcsc_id: str) -> dict: + def get_cad_data_of_component(self, lcsc_id: str): cp_cad_info = self.get_info_from_easyeda_api(lcsc_id=lcsc_id) if cp_cad_info == {}: return {} return cp_cad_info["result"] - def get_raw_3d_model_obj(self, uuid: str) -> str: - r = requests.get( - url=ENDPOINT_3D_MODEL.format(uuid=uuid), - headers={"User-Agent": self.headers["User-Agent"]}, - ) + def get_raw_3d_model_obj(self, uuid: str): + r = requests.get(url=ENDPOINT_3D_MODEL.format(uuid=uuid), headers={"User-Agent": self.headers["User-Agent"]}) if r.status_code != requests.codes.ok: logging.error(f"No 3D model data found for uuid:{uuid} on easyeda") return None return r.content.decode() -def convert_to_mm(dim: float) -> float: +def convert_to_mm(dim: float): return float(dim) * 10 * 0.0254 @@ -102,20 +96,17 @@ class Ki3dModel: class Easyeda3dModelImporter: - def __init__(self, easyeda_cp_cad_data, download_raw_3d_model: bool): + def __init__(self, easyeda_cp_cad_data, download_raw_3d_model=True): self.input = easyeda_cp_cad_data self.download_raw_3d_model = download_raw_3d_model self.output = self.create_3d_model() def create_3d_model(self): - ee_data = ( - self.input["packageDetail"]["dataStr"]["shape"] - if isinstance(self.input, dict) - else self.input - ) + ee_data = self.input["packageDetail"]["dataStr"]["shape"] if isinstance(self.input, dict) else self.input - if model_3d_info := self.get_3d_model_info(ee_data=ee_data): - model_3d: Ee3dModel = self.parse_3d_model_info(info=model_3d_info) + model_3d_info = self.get_3d_model_info(ee_data=ee_data) + if model_3d_info: + model_3d = self.parse_3d_model_info(info=model_3d_info) if self.download_raw_3d_model: model_3d.raw_obj = EasyedaApi().get_raw_3d_model_obj(uuid=model_3d.uuid) return model_3d @@ -123,7 +114,7 @@ class Easyeda3dModelImporter: logging.warning("No 3D model available for this component") return None - def get_3d_model_info(self, ee_data: str) -> dict: + def get_3d_model_info(self, ee_data: str): for line in ee_data: ee_designator = line.split("~")[0] if ee_designator == "SVGNODE": @@ -144,7 +135,7 @@ class Easyeda3dModelImporter: ) -def get_materials(obj_data: str) -> dict: +def get_materials(obj_data: str): material_regex = "newmtl .*?endmtl" matchs = re.findall(pattern=material_regex, string=obj_data, flags=re.DOTALL) @@ -165,18 +156,14 @@ def get_materials(obj_data: str) -> dict: material["transparency"] = value.split(" ")[1] materials[material_id] = material - # logging.error(materials) return materials -def get_vertices(obj_data: str) -> list: +def get_vertices(obj_data: str): vertices_regex = "v (.*?)\n" matchs = re.findall(pattern=vertices_regex, string=obj_data, flags=re.DOTALL) - return [ - " ".join([str(round(float(coord) / 2.54, 4)) for coord in vertice.split(" ")]) - for vertice in matchs - ] + return [" ".join([str(round(float(coord) / 2.54, 4)) for coord in vertice.split(" ")]) for vertice in matchs] def map_materials(mats): @@ -226,7 +213,7 @@ def map_materials(mats): return mat_map -def generate_wrl_model(model_3d: Ee3dModel) -> Ki3dModel: +def generate_wrl_model(model_3d: Ee3dModel): materials = get_materials(obj_data=model_3d.raw_obj) vertices = get_vertices(obj_data=model_3d.raw_obj) mat_map = map_materials(materials) @@ -297,31 +284,37 @@ def generate_wrl_model(model_3d: Ee3dModel) -> Ki3dModel: raw_wrl += shape_str - return Ki3dModel( - translation=None, rotation=None, name=model_3d.name, raw_wrl=raw_wrl - ) + return Ki3dModel(translation=None, rotation=None, name=model_3d.name, raw_wrl=raw_wrl) class Exporter3dModelKicad: def __init__(self, model_3d: Ee3dModel): self.input = model_3d - self.output = ( - generate_wrl_model(model_3d=model_3d) - if model_3d and model_3d.raw_obj - else None - ) + self.output = generate_wrl_model(model_3d=model_3d) if model_3d and model_3d.raw_obj else None - def export(self, lib_path: str) -> None: - if self.output: - with open( - file=f"{lib_path}.3dshapes/{self.output.name}.wrl", - mode="w", - encoding="utf-8", - ) as my_lib: - my_lib.write(self.output.raw_wrl) + def export(self, lib_path: str): + if self.output is None: + return None + fname = os.path.join(lib_path, self.output.name+'.wrl') + with open(fname, "w", encoding="utf-8") as my_lib: + my_lib.write(self.output.raw_wrl) + return fname + + +def download_easyeda_3d_model(lcsc_id, dest_path): + api = EasyedaApi() + data = api.get_cad_data_of_component(lcsc_id) + if not data: + return None + importer = Easyeda3dModelImporter(data, True) + exporter = Exporter3dModelKicad(model_3d=importer.output) + os.makedirs(dest_path, exist_ok=True) + exporter.export(dest_path) component_id = 'C2895617' +download_easyeda_3d_model(component_id, 'test') +exit(0) # if False: a = EasyedaApi() @@ -345,7 +338,7 @@ else: exporter = Exporter3dModelKicad(model_3d=c.output) os.makedirs('a.3dshapes', exist_ok=True) -exporter.export('a') +exporter.export('a.3dshapes') if exporter.output: filename = f"{exporter.output.name}.wrl" lib_path = "a.3dshapes" diff --git a/experiments/blender/README.md b/experiments/blender/README.md index 0a3b0ae5..d38fa122 100644 --- a/experiments/blender/README.md +++ b/experiments/blender/README.md @@ -402,6 +402,7 @@ docker run --rm \ --workdir=$(pwd) \ --volume="/tmp:/tmp" \ --volume="/etc/group:/etc/group:ro" \ + --volume="/etc/gshadow:/etc/gshadow:ro" \ --volume="/etc/timezone:/etc/timezone:ro" \ --volume="/home/$USER:/home/$USER:rw" \ --volume="/etc/passwd:/etc/passwd:ro" \ diff --git a/kibot/EasyEDA/__init__.py b/kibot/EasyEDA/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/kibot/EasyEDA/easyeda_3d.py b/kibot/EasyEDA/easyeda_3d.py new file mode 100644 index 00000000..91bfae47 --- /dev/null +++ b/kibot/EasyEDA/easyeda_3d.py @@ -0,0 +1,323 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2023 Salvador E. Tropea +# Copyright (c) 2023 Instituto Nacional de TecnologĂ­a Industrial +# Copyright (c) 2022 uPesy Electronics (Alexis) +# License: AGPL-3.0 +# Project: KiBot (formerly KiPlot) +# Adapted from: https://github.com/uPesy/easyeda2kicad.py + +from dataclasses import dataclass +import json +import os +import requests +import re +import textwrap +from ..misc import W_EEDA3D +from .. import log + +logger = log.get_logger() +API_ENDPOINT = "https://easyeda.com/api/products/{lcsc_id}/components?version=6.4.19.5" +ENDPOINT_3D_MODEL = "https://easyeda.com/analyzer/api/3dmodel/{uuid}" +VRML_HEADER = "#VRML V2.0 utf8\n# 3D model generated by KiBot (using easyeda2kicad.py code)\n" +USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:102.0) Gecko/20100101 Firefox/102.0" +# KiCad materials that we try to detect +MATERIAL_PIN_01 = {"name": 'PIN-01', "ambient_intensity": 0.271, "shininess": 0.7, "transparency": 0, + "diffuse_color": ('0.824', '0.820', '0.781'), "specular_color": ('0.328', '0.258', '0.172')} +MATERIAL_PIN_02 = {"name": 'PIN-02', "ambient_intensity": 0.379, "shininess": 0.4, "transparency": 0, + "diffuse_color": ('0.859', '0.738', '0.496'), "specular_color": ('0.137', '0.145', '0.184')} +MATERIAL_MET_01 = {"name": 'MET-01', "ambient_intensity": 0.250, "shininess": 0.056, "transparency": 0, + "diffuse_color": ('0.298', '0.298', '0.298'), "specular_color": ('0.398', '0.398', '0.398')} +MATERIAL_EPOXY_04 = {"name": 'IC-BODY-EPOXY-04', "ambient_intensity": 0.293, "shininess": 0.35, "transparency": 0, + "diffuse_color": ('0.148', '0.145', '0.145'), "specular_color": ('0.180', '0.168', '0.160')} + + +class EasyedaApi: + def __init__(self): + self.headers = {"Accept-Encoding": "gzip, deflate", + "Accept": "application/json, text/javascript, */*; q=0.01", + "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8", + "User-Agent": USER_AGENT} + + def get_info_from_easyeda_api(self, lcsc_id): + r = requests.get(url=API_ENDPOINT.format(lcsc_id=lcsc_id), headers=self.headers) + api_response = r.json() + + if not api_response or "code" in api_response and api_response["success"] is False: + logger.warning(W_EEDA3D+"Failed EasyEDA request for `{}`, got: {}".format(lcsc_id, api_response)) + return {} + + return r.json() + + def get_cad_data_of_component(self, lcsc_id): + cp_cad_info = self.get_info_from_easyeda_api(lcsc_id=lcsc_id) + if cp_cad_info == {}: + return {} + return cp_cad_info["result"] + + def get_raw_3d_model_obj(self, uuid): + r = requests.get(url=ENDPOINT_3D_MODEL.format(uuid=uuid), headers={"User-Agent": self.headers["User-Agent"]}) + if r.status_code != requests.codes.ok: + logger.warning(W_EEDA3D+"Failed to download 3D model data found for EasyEDA uuid: "+uuid) + return None + return r.content.decode() + + +def convert_to_mm(dim: float): + return float(dim) * 10 * 0.0254 + + +@dataclass +class Ee3dModelBase: + x: float = 0.0 + y: float = 0.0 + z: float = 0.0 + + def convert_to_mm(self) -> None: + self.x = convert_to_mm(self.x) + self.y = convert_to_mm(self.y) + self.z = convert_to_mm(self.z) + + +@dataclass +class Ee3dModel: + name: str + uuid: str + translation: Ee3dModelBase + rotation: Ee3dModelBase + raw_obj: str = None + + def convert_to_mm(self) -> None: + self.translation.convert_to_mm() + # self.translation.z = self.translation.z + + +@dataclass +class Ki3dModelBase: + x: float = 0.0 + y: float = 0.0 + z: float = 0.0 + + +@dataclass +class Ki3dModel: + name: str + translation: Ki3dModelBase + rotation: Ki3dModelBase + raw_wrl: str = None + + +class Easyeda3dModelImporter: + def __init__(self, easyeda_cp_cad_data, lcsc_id, download_raw_3d_model=True): + self.input = easyeda_cp_cad_data + self.download_raw_3d_model = download_raw_3d_model + self.output = self.create_3d_model(lcsc_id) + + def create_3d_model(self, lcsc_id): + ee_data = self.input["packageDetail"]["dataStr"]["shape"] if isinstance(self.input, dict) else self.input + + model_3d_info = self.get_3d_model_info(ee_data=ee_data) + if model_3d_info: + model_3d = self.parse_3d_model_info(info=model_3d_info) + if self.download_raw_3d_model: + model_3d.raw_obj = EasyedaApi().get_raw_3d_model_obj(uuid=model_3d.uuid) + return model_3d + + logger.warning(W_EEDA3D+"No 3D model available for `{}`".format(lcsc_id)) + return None + + def get_3d_model_info(self, ee_data: str): + for line in ee_data: + ee_designator = line.split("~")[0] + if ee_designator == "SVGNODE": + raw_json = line.split("~")[1:][0] + return json.loads(raw_json)["attrs"] + return {} + + def parse_3d_model_info(self, info: dict): + return Ee3dModel( + name=info["title"], + uuid=info["uuid"], + translation=Ee3dModelBase( + x=info["c_origin"].split(",")[0], + y=info["c_origin"].split(",")[1], + z=info["z"], + ), + rotation=Ee3dModelBase(*(info["c_rotation"].split(","))), + ) + + +def get_materials(obj_data: str): + + material_regex = "newmtl .*?endmtl" + matches = re.findall(pattern=material_regex, string=obj_data, flags=re.DOTALL) + + materials = {} + for c, match in enumerate(matches): + material = {"ambient_intensity": 0.2, "shininess": 0.5, "transparency": 0} + material["name"] = "MATERIAL_"+str(c+1) + for value in match.splitlines(): + if value.startswith("newmtl"): + material_id = value.split(" ")[1] + elif value.startswith("Ka"): + material["ambient_color"] = value.split(" ")[1:] + elif value.startswith("Kd"): + material["diffuse_color"] = value.split(" ")[1:] + elif value.startswith("Ks"): + material["specular_color"] = value.split(" ")[1:] + # elif value.startswith("d"): + # material["transparency"] = value.split(" ")[1] + + materials[material_id] = material + return materials + + +def get_vertices(obj_data: str): + vertices_regex = "v (.*?)\n" + matches = re.findall(pattern=vertices_regex, string=obj_data, flags=re.DOTALL) + + return [" ".join([str(round(float(coord) / 2.54, 4)) for coord in vertice.split(" ")]) for vertice in matches] + + +def map_materials(mats): + """ An heuristic to map generic colors to the ones used by KiCad """ + # Look for grey, black and yellow materials. + greys = [] + blacks = [] + golden = [] + for id, mat in mats.items(): + r, g, b = map(float, mat['diffuse_color']) + if abs(r-g) < 0.02 and abs(g-b) < 0.02: + # Same RGB components + if r < 0.97 and r > 0.6: + # In the 0.6 - 0.97 range + greys.append(id) + if r < 0.3 and r > 0.1: + blacks.append(id) + elif r > 0.8 and g < 0.8 and g > 0.7 and b < 0.5: + golden.append(id) + if (r == 0 and g == 0 and b == 0) or (r == 1 and g == 1 and b == 1): + mat['transparency'] = 1 + else: + mat['transparency'] = 0 + mat['diffuse_color'] = ('%5.3f' % r, '%5.3f' % g, '%5.3f' % b) + mat['specular_color'] = tuple('%5.3f' % float(c) for c in mat['specular_color']) + # Use greys for the pins and metal body + c_greys = len(greys) + if c_greys == 1: + mats[greys[0]] = MATERIAL_PIN_01 + elif c_greys > 1: + # More than one grey, sort by specular level + greys = sorted(greys, key=lambda x: mats[x]['specular_color'][0], reverse=True) + mats[greys[0]] = MATERIAL_PIN_01 + mats[greys[1]] = MATERIAL_MET_01 + # Use black for the plastic body + c_blacks = len(blacks) + if c_blacks == 1: + mats[blacks[0]] = MATERIAL_EPOXY_04 + elif c_blacks > 1: + blacks = sorted(blacks, key=lambda x: mats[x]['diffuse_color'][0], reverse=True) + mats[blacks[0]] = MATERIAL_EPOXY_04 + # for c, b in enumerate(blacks): + # mat_map[b] = 'IC-BODY-EPOXY-%02d' % c+1 + # Use yellow for golden pins + if golden: + mats[golden[0]] = MATERIAL_PIN_02 + + +def generate_wrl_model(model_3d: Ee3dModel): + materials = get_materials(obj_data=model_3d.raw_obj) + vertices = get_vertices(obj_data=model_3d.raw_obj) + map_materials(materials) + + raw_wrl = VRML_HEADER + # Define all the materials + for mat in materials.values(): + mat_str = textwrap.dedent( + f""" + Shape {{ + appearance Appearance {{ + material DEF {mat['name']} Material {{ + ambientIntensity {mat['ambient_intensity']} + diffuseColor {' '.join(mat['diffuse_color'])} + specularColor {' '.join(mat['specular_color'])} + shininess {mat['shininess']} + transparency {mat['transparency']} + }} + }} + }}""" + ) + raw_wrl += mat_str + # Define the shapes + shapes = model_3d.raw_obj.split("usemtl")[1:] + for shape in shapes: + lines = shape.splitlines() + material_id = materials[lines[0].replace(" ", "")]["name"] + index_counter = 0 + link_dict = {} + coord_index = [] + points = [] + for line in lines[1:]: + if len(line) > 0: + face = [int(index) for index in line.replace("//", "").split(" ")[1:]] + face_index = [] + for index in face: + if index not in link_dict: + link_dict[index] = index_counter + face_index.append(str(index_counter)) + points.append(vertices[index - 1]) + index_counter += 1 + else: + face_index.append(str(link_dict[index])) + face_index.append("-1") + coord_index.append(",".join(face_index) + ",") + points.insert(-1, points[-1]) + + shape_str = textwrap.dedent( + f""" + Shape {{ + geometry IndexedFaceSet {{ + ccw TRUE + solid FALSE + coord DEF co Coordinate {{ + point [ + {(", ").join(points)} + ] + }} + coordIndex [ + {"".join(coord_index)} + ] + }} + appearance Appearance {{material USE {material_id}}} + }}""" + ) + + raw_wrl += shape_str + + return Ki3dModel(translation=None, rotation=None, name=model_3d.name, raw_wrl=raw_wrl) + + +class Exporter3dModelKicad: + def __init__(self, model_3d): + self.input = model_3d + self.output = generate_wrl_model(model_3d=model_3d) if model_3d and model_3d.raw_obj else None + + def export(self, lib_path, fname=None): + if self.output is None: + return None + if fname is None: + fname = self.output.name+'.wrl' + fname = os.path.join(lib_path, fname) + with open(fname, "w", encoding="utf-8") as my_lib: + my_lib.write(self.output.raw_wrl) + return fname + + +def download_easyeda_3d_model(lcsc_id, dest_path, fname=None): + api = EasyedaApi() + data = api.get_cad_data_of_component(lcsc_id) + if not data: + return None + importer = Easyeda3dModelImporter(data, lcsc_id) + exporter = Exporter3dModelKicad(model_3d=importer.output) + os.makedirs(dest_path, exist_ok=True) + return exporter.export(dest_path, fname) diff --git a/kibot/misc.py b/kibot/misc.py index 630d2436..93a08faa 100644 --- a/kibot/misc.py +++ b/kibot/misc.py @@ -252,6 +252,7 @@ W_NOPCB3DTL = '(W112) ' W_BADPCB3DTXT = '(W113) ' W_UNKPCB3DNAME = '(W114) ' W_BADPCB3DSTK = '(W115) ' +W_EEDA3D = '(W116) ' # Somehow arbitrary, the colors are real, but can be different PCB_MAT_COLORS = {'fr1': "937042", 'fr2': "949d70", 'fr3': "adacb4", 'fr4': "332B16", 'fr5': "6cc290"} PCB_FINISH_COLORS = {'hal': "8b898c", 'hasl': "8b898c", 'imag': "8b898c", 'enig': "cfb96e", 'enepig': "cfb96e", diff --git a/kibot/out_base_3d.py b/kibot/out_base_3d.py index ed9da4f4..a52872b9 100644 --- a/kibot/out_base_3d.py +++ b/kibot/out_base_3d.py @@ -6,6 +6,7 @@ from fnmatch import fnmatch import os import requests +from .EasyEDA.easyeda_3d import download_easyeda_3d_model from .misc import W_MISS3D, W_FAILDL, W_DOWN3D, DISABLE_3D_MODEL_TEXT from .gs import GS from .optionable import Optionable @@ -85,15 +86,6 @@ class Base3DOptions(VariantOptions): def download_model(self, url, fname, rel_dirs): """ Download the 3D model from the provided URL """ - # Find a place to store the downloaded model - if self._tmp_dir is None: - self._tmp_dir = os.environ.get('KIBOT_3D_MODELS') - if self._tmp_dir is None: - self._tmp_dir = os.path.join(os.path.expanduser('~'), '.cache', 'kibot', '3d') - else: - self._tmp_dir = os.path.abspath(self._tmp_dir) - rel_dirs.append(self._tmp_dir) - logger.debug('Using `{}` as dir for downloaded 3D models'.format(self._tmp_dir)) dest = os.path.join(self._tmp_dir, fname) os.makedirs(os.path.dirname(dest), exist_ok=True) # Is already there? @@ -126,7 +118,57 @@ class Base3DOptions(VariantOptions): return nm return name - def download_models(self, rename_filter=None, rename_function=None, rename_data=None, force_wrl=False): + def try_download_kicad(self, model, full_name, downloaded, rel_dirs, force_wrl): + if not (model.startswith('${KISYS3DMOD}/') or model.startswith('${KICAD6_3DMODEL_DIR}/')): + return None + # This is a model from KiCad, try to download it + fname = model[model.find('/')+1:] + replace = None + if full_name in downloaded: + # Already downloaded + return os.path.join(self._tmp_dir, fname) + # Download the model + url = self.kicad_3d_url+fname + replace = self.download_model(url, fname, rel_dirs) + if not replace: + return None + # Successfully downloaded + downloaded.add(full_name) + # If this is a .wrl also download the .step + if url.endswith('.wrl'): + url = url[:-4]+'.step' + fname = fname[:-4]+'.step' + self.download_model(url, fname, rel_dirs) + elif force_wrl: # This should be a .step, so we download the wrl + url = os.path.splitext(url)[0]+'.wrl' + fname = os.path.splitext(fname)[0]+'.wrl' + self.download_model(url, fname, rel_dirs) + return replace + + def try_download_easyeda(self, model, full_name, downloaded, sch_comp, lcsc_field): + if not lcsc_field or not sch_comp: + return None + lcsc_id = sch_comp.get_field_value(lcsc_field) + if not lcsc_id: + return None + fname = os.path.basename(model) + cache_name = os.path.join(self._tmp_dir, fname) + if full_name in downloaded: + # Already downloaded + return cache_name + if os.path.isfile(cache_name): + downloaded.add(full_name) + logger.debug('Using cached model `{}`'.format(cache_name)) + return cache_name + logger.debug('- Trying to download {} component as {}/{}'.format(lcsc_id, self._tmp_dir, fname)) + replace = download_easyeda_3d_model(lcsc_id, self._tmp_dir, fname) + if not replace: + return None + # Successfully downloaded + downloaded.add(full_name) + return replace + + def download_models(self, rename_filter=None, rename_function=None, rename_data=None, force_wrl=False, all_comps=None): """ Check we have the 3D models. Inform missing models. Try to download the missing models @@ -141,9 +183,26 @@ class Base3DOptions(VariantOptions): is_copy_mode = rename_filter is not None rel_dirs = getattr(rename_data, 'rel_dirs', []) extra_debug = GS.debug_level > 3 + # Get a list of components in the schematic. Enables downloading LCSC parts. + if all_comps is None and GS.sch_file: + GS.load_sch() + all_comps = GS.sch.get_components() + all_comps_hash = {c.ref: c for c in all_comps} + # Find the LCSC field + lcsc_field = self.solve_field_name('_field_lcsc_part', empty_when_none=True) + # Find a place to store the downloaded models + if self._tmp_dir is None: + self._tmp_dir = os.environ.get('KIBOT_3D_MODELS') + if self._tmp_dir is None: + self._tmp_dir = os.path.join(os.path.expanduser('~'), '.cache', 'kibot', '3d') + else: + self._tmp_dir = os.path.abspath(self._tmp_dir) + rel_dirs.append(self._tmp_dir) + logger.debug('Using `{}` as dir for downloaded 3D models'.format(self._tmp_dir)) # Look for all the footprints for m in GS.get_modules(): ref = m.GetReference() + sch_comp = all_comps_hash.get(ref, None) # Extract the models (the iterator returns copies) models = m.Models() models_l = [] @@ -164,30 +223,10 @@ class Base3DOptions(VariantOptions): if not os.path.isfile(full_name): logger.debugl(2, 'Missing 3D model file {} ({})'.format(full_name, m3d.m_Filename)) # Missing 3D model - if self.download and (m3d.m_Filename.startswith('${KISYS3DMOD}/') or - m3d.m_Filename.startswith('${KICAD6_3DMODEL_DIR}/')): - # This is a model from KiCad, try to download it - fname = m3d.m_Filename[m3d.m_Filename.find('/')+1:] - replace = None - if full_name in downloaded: - # Already downloaded - replace = os.path.join(self._tmp_dir, fname) - else: - # Download the model - url = self.kicad_3d_url+fname - replace = self.download_model(url, fname, rel_dirs) - if replace: - # Successfully downloaded - downloaded.add(full_name) - # If this is a .wrl also download the .step - if url.endswith('.wrl'): - url = url[:-4]+'.step' - fname = fname[:-4]+'.step' - self.download_model(url, fname, rel_dirs) - elif force_wrl: # This should be a .step, so we download the wrl - url = os.path.splitext(url)[0]+'.wrl' - fname = os.path.splitext(fname)[0]+'.wrl' - self.download_model(url, fname, rel_dirs) + if self.download: + replace = self.try_download_kicad(m3d.m_Filename, full_name, downloaded, rel_dirs, force_wrl) + if replace is None: + replace = self.try_download_easyeda(m3d.m_Filename, full_name, downloaded, sch_comp, lcsc_field) if replace: source_models.add(replace) old_name = m3d.m_Filename @@ -244,7 +283,7 @@ class Base3DOptions(VariantOptions): return ret return GS.pcb_file self.filter_pcb_components(do_3D=True, do_2D=True, highlight=highlight) - self.download_models(force_wrl=force_wrl) + self.download_models(force_wrl=force_wrl, all_comps=self._comps) fname = self.save_tmp_board() self.unfilter_pcb_components(do_3D=True, do_2D=True) return fname