diff --git a/.gitignore b/.gitignore index 608ecbe..4b5a4e3 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,9 @@ flash.log # --- KiCAD cache --- hardware/kicad/fp-info-cache +# sauvegardes locales et verrous de KiCad +.history/ +*.lck # --- RBD ephemeral push validation cache --- .rbd/.push-validated diff --git a/.rbd/config.yml b/.rbd/config.yml index 868f7d4..27ca659 100644 --- a/.rbd/config.yml +++ b/.rbd/config.yml @@ -10,3 +10,6 @@ test_tagging: linter: command: "find src lib include test -name '*.cpp' -o -name '*.h' | xargs clang-format --dry-run --Werror" + +# commit prefixes outside the RBD code cycle, accepted by the pre-push hook +extra_commit_prefixes: [hw] diff --git a/CLAUDE.md b/CLAUDE.md index 90f378e..a6b54ac 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -93,3 +93,45 @@ find src lib include test -name '*.cpp' -o -name '*.h' | xargs clang-format -i | `USE_CHARGE_PUMP` | Board uses a charge pump for sensor power | | `SLEEP_TIME_IN_SECONDS` | Hibernate duration between measurements | | `BATTERY_VOLTAGE_X10_ID` / `ANALOG1_X10_ID` | X10 meter IDs for reporting | + +## Hardware Conventions + +### Wire colors +Power goes in pairs (thick wire): +| Pair (+ / −) | Use | +|---------------|-----| +| red / black | battery and 5V: B+/B−, VBAT, switched battery rail, VUSB | +| white / blue | regulated or stepped voltages: 3V3, buck/boost outputs (never yellow) | +| grey | speaker | + +Signals (thin wire), by role: +| Color | Use | +|--------|-----| +| yellow | clocks: SCK, BCLK, SCL, SD_CLK | +| green | data: MOSI, DIN, SDA, SD_D0 | +| purple | select / frame: CS, LRC/WS, SD_CMD | +| red | status: DC, interrupts, PIR out | +| orange | reset, detect (RST, USB_DET) | +| brown | enables / analog: BL_EN, VBAT/2, *_SD, *_EN | + +The kit has 10 colors: red, black, white, blue, yellow, green, purple, orange, brown, grey. **Blue is reserved for power** (regulated GND) and never carries a signal. When several buses share colors, tell them apart with 1 / 2 / 3 heat-shrink rings at each end. + +### Wire ends +Label every wire end as **soldered**, **Dupont** or **JST-XH** (with F/M contact), on both ends, and give each wire's length, in every doc and diagram. + +### Resistors +Current-limiting and pull resistors sit **at the board end**, never at the LED or the sensor. + +### Images +Strip EXIF/GPS from every image before committing: `python3 ~/.claude/skills/hardware-change/scripts/strip_exif.py ` (`--check` to verify). + +### Commits +No pre-push hook; prefixes are the RBD ones above plus `hw:` for hardware (`openscad/`, `hardware/`) and `docs:` for doc-only commits. Make granular commits, one per area, and push after each. + +## Modeling + +### OpenSCAD / Printing +- Define all parameters at the top of the file, before any formula that uses them. Expose dimensions as named parameters. +- After editing, render with `openscad -o` and check for errors before saying it's done. +- Don't claim parts fit an Ender 3 (220x220) or CR-10 plate unless you've checked the bounding boxes. Flag any overhang over 45° that a design change introduces. +- Regenerating 3MF files wipes the Orca plate layout, so warn me before doing it. diff --git a/openscad/.gitignore b/openscad/.gitignore new file mode 100644 index 0000000..e5348bc --- /dev/null +++ b/openscad/.gitignore @@ -0,0 +1,2 @@ +out/ +print/*.bak diff --git a/openscad/README.md b/openscad/README.md new file mode 100644 index 0000000..a5ecbce --- /dev/null +++ b/openscad/README.md @@ -0,0 +1,228 @@ +# Boîtiers imprimés 3D — TinySensor v2.2 + +Modèles OpenSCAD paramétriques (testés avec OpenSCAD 2021.01). Matière visée : **PETG**. + +| Au mur | Sur pied | +|---|---| +| ![Vue d'ensemble au mur](rendu.png) | ![Vue d'ensemble sur pied](rendu_pied.png) | + +![Boîtier ouvert : carte et accu 18650](rendu_ouvert.png) + +Pièce de 2 € (Ø25,75 mm) dans les rendus pour l'échelle. + +| Fichier | Contenu | +|---|---| +| `tinysensor_box.scad` | Boîtier mural principal (base + couvercle) | +| `sensor_shield.scad` | Abri de sonde extérieur pour breakout BMx280 + clip optionnel | +| `stand.scad` | Pied de table : mât + semelle, pour poser boîtier + abri debout | +| `pcb.scad` | Représentation simplifiée de la carte v2.2 et de l'accu 18650 (rendus uniquement) | +| `euro2.scad` | Pièce de 2 € pour l'échelle des rendus | +| `mushroom.scad` | Boîtier « champignon » décoratif (en plus du boîtier mural) | +| `harness.py` | Générateur du plan de câblage imprimable du champignon (`python3 harness.py` → `faisceau_champignon.pdf`, A4) | +| `assembly.scad` | Vue d'ensemble (boîtier + abri clipsé, au mur ou sur pied), sert à générer `rendu.png` et `rendu_pied.png` | + +> Statut : **non imprimé, non validé**. Jeux et serrages à confirmer au premier tirage. + +## Export + +Les pièces prêtes à trancher sont dans `print/`, **un projet OrcaSlicer par modèle**, une plaque par usage et chaque objet nommé : + +| Projet | Plaques | Objets | +|---|---|---| +| `box.3mf` | Fond extérieur, Fond intérieur, Couvercle | n'imprimer qu'un des deux fonds : `ext` (presse-étoupe, extérieur) ou `int` (aérations, intérieur) | +| `shield.3mf` | Abri, Assiettes | Chapeau, Fond, Clip (optionnel) ; 4 assiettes | +| `stand.3mf` | Mât, Pied | pied de table, optionnel | +| `mushroom.3mf` | Ailettes, Hat, White buttons, Ground, Divers, Foot, Support | Chapeau, Lamelles, Châssis, Plancher, Pied, Sol, Berceau, Bouchon, Pois Ø6 / Ø8 / Ø10 | + +Après une modification d'un `.scad` (environ 30 s, fermer les projets dans Orca avant) : + +```bash +./export.sh # tous les projets +./export.sh print/box.3mf # ou un seul +``` + +`export.sh` lance `update_3mf.py`, qui réexporte chaque pièce et remplace son maillage dans le projet : plaques, positions, rotations et réglages Orca sont gardés, l'ancien projet reste en `print/.3mf.bak`. Un projet absent est recréé, avec les réglages d'imprimante et de filament de `mushroom.3mf` (Ender 3). Les pièces ne doivent être tournées qu'autour de Z dans Orca, sinon le script refuse de les mettre à jour ; une pièce supprimée dans Orca est signalée, pas remise. Réglages par objet posés s'ils manquent : chapeau du champignon brim extérieur 10 mm, remplissage 10 % ; pied du champignon 6 parois. Les nombres de pois sont ceux du projet : l'écho « Pois » de `mushroom.scad` donne le compte à placer si la graine ou `dot_gap` changent. + +Export d'une pièce hors projet (STL, autre format) : `openscad -D 'part="lid"' -o out/lid.3mf tinysensor_box.scad` (voir l'en-tête de chaque `.scad`). + +Rendu boîtier ouvert : `openscad --preview -D 'part="open"' --camera=110,-230,420,110,30,0 --imgsize=1400,1000 -o rendu_ouvert.png tinysensor_box.scad`. + +Toutes les pièces sont exportées dans leur orientation d'impression et s'impriment **sans support**, y compris le dôme du champignon. `out/` est ignoré par git, pas `print/`. + +## Boîtier principal (`tinysensor_box.scad`) + +- Calé sur le PCB v2.2 (`hardware/tinysensor/tinySensorv1.kicad_pcb`) : 93,98 × 63,75 mm, fixation par les 4 trous M2.5 des coins sur entretoises de 5 mm. +- Dégagement au-dessus du PCB : 25 mm (gros condensateurs), paramètre `comp_h`. +- Baie libre de 14 mm côté bas (côté support 18650) pour câbles et antenne. +- Encombrement : ~117 × 101 × 39 mm (rebord compris, couvercle inclus). +- **Fermeture** : 6 vis M3 auto-taraudeuses, hors de la zone joint. +- **Étanchéité** : gorge 2,2 × 1,5 mm pour cordon Ø2 (TPU imprimé ou EPDM), écrasé par une nervure du couvercle. +- **Fixation murale** : 2 pattes, vis Ø4 tête fraisée. Le fond reste plein. +- Variantes (`usage`) : + - `ext` : trou Ø12,5 pour presse-étoupe **PG7** (sonde déportée). Par défaut en **paroi latérale gauche**, au niveau de la baie, sous le chanfrein du rebord qui l'abrite de la pluie ; `gland_side` = `left` / `right` / `bottom` (ancienne position, paroi basse). Le câble redescend le long du boîtier puis remonte dans l'abri par le fond : prévoir ~15 cm de plus et une boucle d'égouttement. + - `int` : fentes d'aération haut et bas (convection). +- La patte basse porte deux encoches latérales pour le clip de l'abri (`shield_notch`). + +## Abri de sonde (`sensor_shield.scad`) + +Abri à assiettes (type Stevenson) : en extérieur, un BMx280 enfermé dans le boîtier étanche mesurerait l'air du boîtier, pas l'air ambiant. + +- Chapeau plein + 4 assiettes ajourées + fond, Ø56 mm, pas de 10 mm, hauteur ~52 mm. +- Assemblage : 3 tiges filetées **M3** traversant les entretoises, écrous sous le fond. +- Fond : glissière verticale pour le breakout **15 × 11 mm, 6 broches** (empreinte `BMx280_breakout`), passe-câble Ø6 dessous, 6 trous de drainage. +- Imprimer en **blanc** (limite l'échauffement solaire). Poser à l'ombre si possible. + +### Fixation (au choix) + +1. **Murale** : platine à 2 vis Ø4 fraisées côté sonde. Visser le chapeau au mur **avant** d'empiler les assiettes. +2. **Clip sous le boîtier** (`part="clip"`) : adaptateur boulonné derrière la platine (2 vis M4×8 tête fraisée + écrous noyés côté mur). Ses deux branches enserrent la patte murale basse du boîtier (lèvres à 45°) et un bossage s'enclenche dans chaque encoche. Pose : visser le boîtier, glisser l'abri vers le haut jusqu'au clic. + - Serrage réglable : `bump_r` (accroche), `prong_w` (souplesse). + +3. **Sur pied** (`stand.scad`) : voir ci-dessous. + +## Pied de table (`stand.scad`) + +Pour poser l'ensemble boîtier + abri debout sur une table, sans mur. + +- **Mât** 30 × 6 × 212 mm : il tient lieu de mur. Le boîtier s'y visse par ses deux pattes murales (M4 tête fraisée, écrous noyés dans le dos du mât) ; l'abri reste clipsé sous la patte basse, comme au mur. +- **Semelle** 130 × 136 mm avec douille de 25 mm et goussets : le mât s'y emboîte et se bloque par une vis M4 traversante. Elle déborde de 90 mm vers l'avant pour équilibrer l'abri, qui avance jusqu'à ~72 mm du mât. +- 30 mm de dégagement sous l'abri (`under_clr`) pour la boucle du câble qui remonte par le fond. +- Le mât (212 mm) tient sur un plateau 220 × 220. +- Les cotes du boîtier et de l'abri sont recopiées en tête de fichier : à réaligner si on modifie les autres modèles. + +### Câblage + +- Liaison I2C < 1 m : pull-ups 4,7 kΩ, câble torsadé (SDA/GND, SCL/GND). +- Le câble sort du presse-étoupe et remonte dans l'abri par le fond. + +## Champignon (`mushroom.scad`) + +Variante décorative, type amanite, qui ne remplace pas le boîtier mural. Le chapeau est un tout (dôme + lamelles) posé sur le pied avec une légère inclinaison ; toute l'électronique est dans le chapeau, le pied ne porte que l'accu. + +| Vue d'ensemble | Pied | Dessous | Coupe | +|---|---|---|---| +| ![Champignon](rendu_champignon.png) | ![Champignon, pied](rendu_champignon_pied.png) | ![Champignon, dessous](rendu_champignon_dessous.png) | ![Champignon, coupe](rendu_champignon_coupe.png) | + +Pièces : + +- **Dôme** (`cap`) ~147 × 147 × 190 mm (rayon maxi moyen 72) : **gourde** d'après croquis, méridien `cap_shape` (bas large, taille Ø~55, haut Ø~72), **contour lobé** issu d'un générateur (voir ci-dessous). À partir de la zone qui se resserre (`cap_neck`), le haut penche de 8° vers −X (`cap_lean`, contre l'inclinaison du chapeau), pour ramener la masse vers l'axe du pied : sommet à ~6 mm de l'axe du pied côté +X. La carte est tournée de 180° dans le châssis pour que le sommet reste à l'aplomb de **J1 « RADIO »** (module 433 MHz), selon l'axe de la carte (`cap_top` est calculé), à 126 mm au-dessus de la carte. **Évent** Ø5 (`vent_d`) : de la pointe du plafond, il descend de ~15° vers le flanc arrière (+Y), sans pois autour ; la pluie n'entre pas et l'air chaud sort, à l'opposé de J1. Le gros du bas part à l'opposé, côté évasement du pied. Plafond intérieur conique à 45°, sans support : le haut est plein sous le sommet. Son bord descend de 4 mm sous le disque à lamelles (cache sa tranche) et s'épaissit vers l'intérieur à **6 mm de large** (`rim_w`, raccord à 45°) : ~2,4 fois plus de surface sur le plateau pour une pièce de 190 mm de haut, silhouette inchangée. Fixation : 3 **consoles** sur la paroi intérieure, juste au-dessus du disque (`corbel_*` : 12 mm de large, face droite de 10 mm puis chanfrein à 45° jusqu'à la paroi, trou pilote Ø2,5 × 8) ; le disque monte dans le dôme et y est plaqué par 3 vis M3 × 10 auto-taraudeuses par en dessous, têtes sous le disque entre les lamelles. ![Coupe par une console](rendu_champignon_console.png) +- **Lamelles** (`gills`) : disque qui ferme le dessous du chapeau, au contour du plus étroit de la jupe du dôme moins 0,4 mm (`disc_fit`) pour passer les creux des lobes, 48 lames radiales plus profondes près du pied (`gill_depth`), fentes de ventilation entre les lames sous la chambre du capteur (y compris côté +X, à 0° et ±7,5°, au ras du bord +X du BME280), ouverture de la prise USB, fourreau central qui reçoit le tenon du pied. S'imprime lames vers le haut. +- **Châssis** (`chassis`) vissé sur le disque (4 vis M3 par en dessous), imprimé debout : + - chambre du bas (48 mm, `chamber_h`), ventilée : **BME280 enfiché sous la carte**, sans fil ni glissière (voir *Câblage*) ; le breakout pend parallèle à la carte, capteur vers le disque, à 42 mm au-dessus du disque ; fenêtres du châssis en haut de la chambre (`win_h`), pour que l'air passe devant le capteur. Le haut du berceau traverse le disque de 30 mm (trou Ø37, `disc_bore`) et finit 12 mm sous le breakout, avec la paire JST-XH ; + - **plancher percé** (`deck`) sur des appuis, avec une fenêtre de 17,2 × 4,5 mm pour la barrette femelle ; + - **carte v2.2 découpée** (60 × 39 mm, le long des pointillés de sérigraphie x = 147 / y = 87, sans les supports 18650 et CR2032) : tournée de 180° (J1 côté −X), 2 vis M2.5 (MH5, MH1 du haut), le bord bas repose sur un appui. L'accu arrive sur **J13 « RAW IN »** (`+BATT`/`GND`) ; + - glissière du **module de charge** TP4056, vertical sur le côté, prise **micro-USB vers le bas** entre deux lamelles. +- **Pied** (`stem`) en **poire d'amanite** d'après croquis : base bulbeuse (~106 × 96 mm au ventre, Ø70 à Ø85 sur la table), col Ø44 à 50 à l'anneau, Ø45 sous le fourreau, creux (Ø29,4) jusqu'en haut : reçoit le berceau de l'accu, inséré par le haut, posé à 72 mm du sol. Dessous, une **chambre à lest** fermée qui épouse la forme du pied (paroi de 2,4 mm, `ballast_wall`, bulbe compris), plafond conique à 45° jusqu'à 70 mm (`ballast_h`) : ~220 cm³, soit ~1 kg de billes d'acier de 1 mm si on la remplit. Remplissage par un trou de Ø15 dans le fond, fermé par un **bouchon** (`plug`, Ø21 × 1,2 mm) collé dans un lamage, à fleur du dessous. Même esprit que le chapeau : + - fût lobé (`stem_amp`, lobes qui tournent le long du fût, `stem_twist`) ; la déformation n'ajoute que de la matière, la paroi autour de l'accu ne s'amincit pas, et s'annule sous le fourreau ; + - base bulbeuse dissymétrique (`bulb_r`, `bulb_z`, `bulb_up`, `bulb_dn`, `foot_dir`) : épaule plus marquée d'un côté, dessous arrondi à 44° au plus ; + - anneau en **coupelle** sous le chapeau (`ring_z`, `ring_ext`, `ring_rise`), bord relevé et ondulé (`ring_amp`), 45° au plus : sans support. + - En haut, un tenon incliné de 6° (`cap_tilt`) s'emboîte dans le fourreau, 1 vis M3 radiale. +- **Berceau** (`cradle`) 74,8 × Ø28,6 mm : gouttière amovible pour une **18650 nue** (65 mm, téton plat ; `cell_l` pour une autre longueur), sans soudure sur l'accu. Deux **butées à languette** (plaque 13 × 11 mm, languette 3 × 7 percée) glissées dans des rainures : ressort côté « − » en bas, bossage côté « + » en haut (polarité gravée sur les bouts). Les languettes traversent le fond et sortent dans le méplat, où passent les fils jusqu'au fourreau (W1 rouge 40 mm, W2 noir 110 mm, soudé → JST-XH F, voir *Câblage*) ; on soude les fils sur les languettes, pas sur l'accu. L'accu entre par la fente du dessus en comprimant le ressort (≈ 4 mm, `spring_c`). Trou de tirage Ø2,5 en haut (ficelle, nœud à l'intérieur). Imprimé à plat, méplat sur le plateau, sans support. ![Berceau : monté et nu](rendu_champignon_berceau.png) +- **Sol** (`ground`) : disque de 1 mm au contour lobé (Ø~145, `ground_R`), frange d'herbe en zigzag autour du pied, touffes de grands brins et brins isolés (`grass_*`, `fringe_h`). Pièce séparée à imprimer en vert ; le pied se colle dessus. Élargit l'appui au sol. +- **Pois** (`dot`) rapportés, collés dans des logements de 0,8 mm, répartis par tirage sur la surface réelle (écart mini `dot_gap`) : 99 (Ø6 × 45, Ø8 × 26, Ø10 × 28), l'écho OpenSCAD donne le nombre par taille. + +Hauteur totale ~300 mm. + +### Contour du chapeau + +Le contour vu de dessus est une somme d'harmoniques (2 à 7) à phases aléatoires, pondérées autour de `lobe_n` lobes et normalisées à `lobe_amp` (écart maxi au rayon moyen, ±22 % par défaut). La déformation est pleine au bord et s'atténue vers le sommet (`lobe_top` = part qui reste en haut). Le bord reste plat (impression bord sur le plateau). + +- `lobe_seed` change la forme. Planche des graines 1 à 8 (vue de dessus) : ![Graines](champignon_graines.png) +- `lobe_rot` : orientation du contour (165° pour la graine 6) ; `-1` : OpenSCAD choisit l'orientation qui laisse le plus de jeu autour de l'électronique (creux face aux petits côtés du châssis, lobe sur le chargeur). Les 3 vis du dôme se placent d'elles-mêmes dans les lobes. +- La console affiche l'**indice de jeu** : ≤ 1 = châssis, carte (composants ≤ 14 mm) et module de charge à ≥ 3 mm du dôme partout ; sinon un avertissement (augmenter `cap_R` / `cap_H` ou changer de graine). La console affiche aussi la marge des fils au-dessus du berceau ; `stem_h` et `ballast_h` ne descendent pas sous 5 mm de marge (assert). + +### Impression + +- Toutes les pièces sortent dans leur sens d'impression. Le **dôme** (190 mm de haut, tient sur Ender 3) s'imprime bord sur le plateau, **sans support** : imprimé en PETG (~20 h), les faces d'appui des consoles, à 7 mm du plateau, passent en pont sur plus de 20 mm avec deux angles droits. Remplissage faible (~10 %) : le haut est plein dans le modèle (~60 cm³, 76 g à 100 %, ~8 g à 10 %), juste pour se passer de supports derrière le col, et il pèse au mauvais endroit. Les autres pièces s'impriment sans support (pied : 113 mm de haut). +- Pied : **6 parois** (buse 0,4) : la paroi de la chambre à lest fait 2,4 mm (`ballast_wall`), elle est ainsi pleine sans remplissage d'interstices. 4 parois suffisent pour les autres pièces. +- Dôme en rouge (ou brun : panthère), pois en blanc, sol en vert, le reste en blanc / ivoire. +- Sol : brins et frange de 0,9 mm d'épaisseur, pointes à 0,6 mm (buse 0,4) ; le slicer peut tronquer les pointes les plus fines. + +### Montage, accu + +0. Pied à l'envers : grenaille par le trou du fond (quelques gouttes de colle pour la figer et éviter qu'elle sonne), bouchon collé dans son lamage (époxy), puis pied collé sur le sol. +1. Carte : souder une **barrette femelle 2,54 1×6** sur le **dessous** de la carte, dans l'empreinte « I2C » ; les JST « I2C » et « I2C_SPI » ne sont pas posés (voir *Câblage*). Côté breakout, souder sa barrette mâle de façon que la puce regarde le disque une fois enfiché (corps du breakout vers l'intérieur du châssis, à vérifier selon le modèle de breakout). +2. Module de charge : souder W5 / W6 (rouge / noir, 70 mm, OUT+ / OUT− soudé → bornier à vis J13). W3 / W4 se soudent à l'étape 3 : le boîtier JST-XH (~7,4 × 5,8 mm) passe de justesse dans la fenêtre de la paroi +Y (8 mm de large), on passe donc l'extrémité nue. +3. Plancher posé dans le châssis, carte vissée par-dessus (la barrette femelle traverse la fenêtre du plancher) ; châssis retourné, BME280 enfiché par le dessous ouvert du châssis. Module de charge dans sa glissière ; W5 / W6 (rouge / noir, 70 mm, TP4056 soudé → bornier à vis) vissés sur J13 ; W3 / W4 (rouge / noir, 100 mm, TP4056 soudé → JST-XH M) : JST-XH M déjà serti, côté chambre ; pousser l'autre extrémité, nue, de l'intérieur de la chambre vers l'extérieur par la fenêtre de la paroi +Y (x = 22), puis la souder sur B+ / B− du TP4056 ; le côté JST reste dans la chambre, au-dessus du berceau. Enfin châssis sur le disque à lamelles (4 vis par en dessous). +4. Disque à lamelles poussé dans le dôme jusqu'aux 3 consoles, 3 vis M3 × 10 auto-taraudeuses par en dessous (têtes entre les lamelles). L'antenne du module 433 MHz monte droit de J1 dans le haut de la gourde. +5. Berceau : butées dans les rainures, W1 rouge 40 mm / W2 noir 110 mm soudés sur les languettes (W2 dans le méplat) → JST-XH F ; accu clipsé ; berceau dans le pied par le haut (ressort en bas). +6. Chapeau tenu ~50 mm au-dessus du pied, brancher la paire JST-XH, la loger dans la chambre juste au-dessus du berceau (trou central du disque), chapeau emboîté sur le tenon, vis radiale. +7. Changer l'accu : retirer la vis radiale, soulever le chapeau (~50 mm) : la paire JST-XH apparaît, la débrancher, sortir le berceau par la ficelle. + +### Câblage (champignon) + +Plan imprimable A4 : [`faisceau_champignon.pdf`](faisceau_champignon.pdf) (généré par `python3 harness.py`). + +**Capteur, sans fil.** Le breakout BME280 (6 broches VIN GND SCL SDA CSB SDO, ~15 × 11 mm) s'enfiche directement par sa barrette mâle dans une **barrette femelle 2,54 1×6** soudée sous la carte, dans l'empreinte « I2C » (JST-EH 6, pas 2,50 : décalage cumulé de 0,2 mm sur 6 positions, absorbé par le jeu des trous Ø0,95). + +- Positions 1-4 = SENSOR_VCC, GND, SCL, SDA = VIN, GND, SCL, SDA. +- Position 5 non connectée = CSB : le tirage du breakout garde le mode I2C. +- Position 6 = GND = SDO : adresse **0x76**, celle qu'attend le firmware. +- Pull-ups I2C R2 / R3 4,7 kΩ sur la carte (côté carte) ; le breakout porte aussi les siennes. +- Hauteur : femelle 8,5 mm + plastique mâle 2,5 mm ; le breakout est au-dessus du berceau, à 42 mm du disque ; l'air monte par les fentes du disque, passe devant le capteur, sort par les fenêtres hautes du châssis et l'évent du sommet. + +![BME280 enfiché sous la carte](rendu_champignon_capteur.png) + +**Accu** (fils 22–24 AWG : plus petit numéro = plus gros fil, W1-W4 portent le courant de charge de 1 A ; longueurs avec 15 à 25 mm de marge, à ajuster au montage) : + +| # | Couleur | Signal | De (extrémité) | Vers (extrémité) | Longueur | Section | +|---|---|---|---|---|---|---| +| W1 | rouge | B+ | languette « + » du berceau (haut) — soudé | JST-XH 2p, broche 1 — JST-XH F (côté berceau) | 40 mm | 22–24 AWG | +| W2 | noir | B− | languette « − » du berceau (bas, passe dans le méplat) — soudé | JST-XH 2p, broche 2 — JST-XH F | 110 mm | 22–24 AWG | +| W3 | rouge | B+ | JST-XH 2p broche 1 — JST-XH M (côté chapeau) | TP4056 B+ — soudé | 100 mm | 22–24 AWG | +| W4 | noir | B− | JST-XH 2p broche 2 — JST-XH M | TP4056 B− — soudé | 100 mm | 22–24 AWG | +| W5 | rouge | +BATT | TP4056 OUT+ — soudé | J13 « RAW IN » borne 1 (+BATT) — bornier à vis | 70 mm | 22–24 AWG | +| W6 | noir | GND | TP4056 OUT− — soudé | J13 borne 2 (GND) — bornier à vis | 70 mm | 22–24 AWG | + +Cheminement : + +- Paire JST-XH (W1 rouge 40 mm / W2 noir 110 mm, languettes soudé → JST-XH F ; W3 / W4 rouge / noir 100 mm, JST-XH M → TP4056 soudé) dans la chambre, juste au-dessus du berceau : soulever le chapeau d'environ 50 mm suffit pour la débrancher par le trou central du disque. +- W3 / W4 (rouge / noir, 100 mm, JST-XH M → TP4056 soudé) sortent de la chambre du capteur par la fenêtre de la paroi +Y (x = 22) et rejoignent le TP4056 (module **avec protection** : pastilles B+ / B− / OUT+ / OUT−, micro-USB vers le bas). +- W5 / W6 (rouge / noir, 70 mm, soudé → bornier à vis) passent par-dessus la paroi +X du châssis jusqu'à J13, tout proche. +- Repérage : W3 / W4 et W5 / W6 sont deux paires rouge / noir soudées côte à côte sur le TP4056. **1 bague** de gaine thermo à chaque bout de W3 / W4, **2 bagues** sur W5 / W6 : une inversion B± / OUT± contourne la protection DW01. +- JST-XH pré-sertis : vérifier rouge = broche 1 des deux côtés (M et F) avant de souder. +- Antenne 433 MHz : droite, de J1 « RADIO » dans le haut de la gourde (sommet à l'aplomb de J1). + +### Points ouverts + +- Cotes à mesurer : butées (épaisseur de tôle `bt_t`, ressort comprimé `spring_c`), position de la prise du module de charge (`usb_cut`), breakout BME (`bme_w/d`, `bme_edge`, hauteur de la barrette femelle `fh_h`). +- Pas de lame d'air entre le dôme et la carte : le capteur est sous le plancher, ventilé par en dessous ; échauffement au soleil à vérifier par mesure. +- Stabilité : réglée par le lest, à vérifier une fois imprimé. Estimation (coque PETG pleine, appui r ~38 mm) : basculement vers ~15° sans lest, ~21° avec 350 g, ~28° avec la chambre pleine (~1 kg, ~1,7 kg au total, centre de gravité à ~69 mm). Un remplissage partiel suffit : à figer à la colle, pied à l'endroit. + +## Visserie + +| Usage | Qté | Référence | +|---|---|---| +| PCB → entretoises | 4 | M2.5 × 6 auto-taraudeuse | +| Couvercle | 6 | M3 × 8 auto-taraudeuse | +| Boîtier → mur | 2 | Ø4 tête fraisée + chevilles | +| Presse-étoupe | 1 | PG7 | +| Joint | ~45 cm | cordon Ø2 TPU/EPDM | +| Abri : assemblage | 3 | tige filetée M3 ~60 mm + 6 écrous | +| Abri → mur (option 1) | 2 | Ø4 tête fraisée | +| Clip → abri (option 2) | 2 | M4 × 8 tête fraisée + écrous | +| Boîtier → mât (pied) | 2 | M4 × 10 tête fraisée + écrous | +| Mât → semelle (pied) | 1 | M4 × 20 + écrou | +| Champignon : carte → châssis | 2 | M2.5 × 6 auto-taraudeuse | +| Champignon : châssis → disque | 4 | M3 × 8 auto-taraudeuse | +| Champignon : dôme → disque | 3 | M3 × 10 auto-taraudeuse (dans les consoles) | +| Champignon : chapeau → pied | 1 | M3 × 8 auto-taraudeuse (radiale) | + +## Connectique et électronique (champignon) + +| Usage | Qté | Référence | +|---|---|---| +| Capteur → carte (dessous du PCB, empreinte I2C) | 1 | barrette femelle 2,54 1×6 | +| Accu ↔ chapeau | 1 | paire JST-XH 2 broches mâle / femelle (fils pré-sertis ou à sertir) | +| Charge | 1 | module TP4056 micro-USB avec protection (DW01), 28,7 × 17,15 × 0,9 mm | +| Berceau | 1 jeu | butées à languette (ressort « − » + bossage « + », plaque 13 × 11 mm) | +| Faisceau W1-W6 | ~0,5 m | fil 22–24 AWG rouge et noir | +| Soudures | — | gaine thermo | +| Accu | 1 | 18650 nu, téton plat, 65 mm | +| Capteur | 1 | breakout BME280 6 broches (adresse 0x76) | + +## Alimentation (à trancher) + +- Support 18650 sur le PCB. +- Boîtiers AA/AAA disponibles : à intégrer dans la baie ou dans un compartiment séparé (dimensions à fournir). +- Solaire : le TP4056 (chargeur linéaire, sans MPPT) est peu adapté ; préférer un CN3065 ou un CN3791. diff --git a/openscad/assembly.scad b/openscad/assembly.scad new file mode 100644 index 0000000..e5540d5 --- /dev/null +++ b/openscad/assembly.scad @@ -0,0 +1,38 @@ +// Vue d'ensemble : boîtier version "ext" + abri de sonde clipsé dessous, +// au mur (mount = "wall") ou sur le pied de table (mount = "stand", stand.scad). +// Les paramètres ci-dessous doivent rester alignés sur les modèles. +// Rendu : openscad --preview --camera=0,0,0,-20,-25,0,0 --viewall --autocenter --imgsize=1000,1200 -o rendu.png assembly.scad +// openscad --preview -D 'mount="stand"' --camera=0,0,0,-20,-35,0,0 --viewall --autocenter --imgsize=1000,1200 -o rendu_pied.png assembly.scad + +mount = "wall"; // [wall, stand] + +use +use +use +use +// paramètres repris des deux fichiers +cav_w = 95.98; reach = 22.4; base_h = 2.4 + 5 + 1.6 + 25 + 2; +gap = 10; plate_t = 1.6; n = 4; stack_h = (n + 1) * gap + plate_t; +wall_y = 40; clip_t = 4; clip_gap = 6; +cx = cav_w / 2; oy = -reach - (stack_h + clip_gap); W = wall_y + clip_t; +// mur + +// boîtier +color("DimGray") base(usage = "ext"); +color("Gray") translate([0, 0, base_h]) lid(); +// abri (coords d'usage de l'abri -> coords boîtier) +multmatrix([[1, 0, 0, cx], [0, 0, 1, oy], [0, -1, 0, W], [0, 0, 0, 1]]) { + color("White") base_print(); + for (i = [1 : n]) color("WhiteSmoke") translate([0, 0, i * gap + plate_t]) mirror([0, 0, 1]) plate_print(); + color("Gainsboro") translate([0, 0, stack_h]) mirror([0, 0, 1]) cap_print(); +} +color("SteelBlue") translate([cx, oy, 0]) clip_use(); +if (mount == "stand") { + color("Peru") mast_use(); + color("Sienna") foot_use(); +} + +// pièce de 2 € pour l'échelle (euro2.scad) +// au mur : à plat contre le mur, à droite de l'abri ; sur pied : posée sur la table devant la semelle +if (mount == "stand") translate([cx + 45, -114, 115]) rotate([-90, 0, 0]) euro2(); +else translate([cav_w + 35, -60, 0]) euro2(); diff --git a/openscad/champignon_graines.png b/openscad/champignon_graines.png new file mode 100644 index 0000000..795736a Binary files /dev/null and b/openscad/champignon_graines.png differ diff --git a/openscad/euro2.scad b/openscad/euro2.scad new file mode 100644 index 0000000..2002fc5 --- /dev/null +++ b/openscad/euro2.scad @@ -0,0 +1,7 @@ +// Pièce de 2 € (Ø25,75 × 2,2 mm, cœur Ø18,75), posée à plat sur Z = 0 : sert d'échelle dans les rendus. +module euro2() { + color("Silver") difference() { cylinder(d = 25.75, h = 2.2, $fn = 96); translate([0, 0, -0.1]) cylinder(d = 18.75, h = 2.4, $fn = 96); } + color("Gold") cylinder(d = 18.75, h = 2.2, $fn = 96); +} + +euro2(); diff --git a/openscad/export.sh b/openscad/export.sh new file mode 100755 index 0000000..6d02c27 --- /dev/null +++ b/openscad/export.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +# Met à jour les projets Orca de print/ (un par modèle) depuis les .scad : plaques, positions et réglages +# gardés, projet créé s'il manque. Détail : update_3mf.py. +# Usage : ./export.sh [projet.3mf ...] (par défaut tous : box, shield, stand, mushroom) +set -euo pipefail +cd "$(dirname "$0")" +python3 update_3mf.py "$@" diff --git a/openscad/faisceau_champignon.pdf b/openscad/faisceau_champignon.pdf new file mode 100644 index 0000000..3ab1b5a Binary files /dev/null and b/openscad/faisceau_champignon.pdf differ diff --git a/openscad/harness.py b/openscad/harness.py new file mode 100644 index 0000000..437243a --- /dev/null +++ b/openscad/harness.py @@ -0,0 +1,254 @@ +#!/usr/bin/env python3 +"""Faisceau accu du champignon TinySensor -> faisceau_champignon.pdf (A4 portrait, 300 dpi). + +Usage : python3 harness.py [sortie.pdf] +Dépendance : Pillow seulement. Police : DejaVu Sans (fc-list | grep DejaVuSans). +Les données sont dans WIRES (une ligne par fil) : modifier là, puis relancer. +""" +import sys +from pathlib import Path +from PIL import Image, ImageDraw, ImageFont + +HERE = Path(__file__).resolve().parent +OUT = Path(sys.argv[1]) if len(sys.argv) > 1 else HERE / "faisceau_champignon.pdf" + +# --- Données ----------------------------------------------------------------------------- +# id, couleur, signal, de (repère schéma, texte), extrémité de départ, vers (repère, texte), +# extrémité d'arrivée, longueur (mm), section +WIRES = [ + ("W1", "rouge", "B+", "bat+", "languette « + » du berceau (haut)", "soudé", + "jstF1", "JST-XH 2p, broche 1", "JST-XH F (côté berceau)", 40, "22–24 AWG"), + ("W2", "noir", "B−", "bat-", "languette « − » du berceau (bas, passe dans le méplat)", "soudé", + "jstF2", "JST-XH 2p, broche 2", "JST-XH F (côté berceau)", 110, "22–24 AWG"), + ("W3", "rouge", "B+", "jstM1", "JST-XH 2p, broche 1", "JST-XH M (côté chapeau)", + "tpB+", "TP4056 B+", "soudé", 100, "22–24 AWG"), + ("W4", "noir", "B−", "jstM2", "JST-XH 2p, broche 2", "JST-XH M (côté chapeau)", + "tpB-", "TP4056 B−", "soudé", 100, "22–24 AWG"), + ("W5", "rouge", "+BATT", "tpO+", "TP4056 OUT+", "soudé", + "j13-1", "J13 « RAW IN » borne 1 (+BATT)", "bornier à vis", 70, "22–24 AWG"), + ("W6", "noir", "GND", "tpO-", "TP4056 OUT−", "soudé", + "j13-2", "J13 borne 2 (GND)", "bornier à vis", 70, "22–24 AWG"), +] +COLORS = {"rouge": (200, 20, 20), "noir": (0, 0, 0)} + +NOTES = [ + "Capteur BME280 : aucun fil. Sa barrette mâle 6 broches s'enfiche directement dans une barrette " + "femelle 2,54 mm de 6 positions soudée sous la carte, dans l'empreinte « I2C » (1 SENSOR_VCC, " + "2 GND, 3 SCL, 4 SDA, 5 non connectée, 6 GND). Le breakout pend parallèle à la carte, 42 mm au-dessus du disque à lamelles.", + "CSB tombe sur la broche 5 (non connectée : le tirage du breakout garde le mode I2C), SDO sur la 6 " + "(GND : adresse 0x76, celle du firmware).", + "Pas de résistance dans le faisceau : les tirages I2C R2/R3 (4,7 kΩ) sont sur la carte.", + "La paire JST-XH se loge dans la chambre, juste au-dessus du berceau : on soulève " + "le chapeau (≈ 50 mm) pour la débrancher par le trou central du disque.", + "W3/W4 sortent de la " + "chambre du capteur par la fenêtre de la paroi +Y (x = 22) et rejoignent le TP4056 (debout dans " + "sa glissière à x = 40, micro-USB vers le bas, pastilles en haut). W5/W6 passent par-dessus la paroi +X du châssis jusqu'à J13.", + "L'antenne monte droit depuis le module radio (J1 « RADIO ») dans le haut de la gourde, dont le sommet est dans l'axe de J1.", + "Repérage au TP4056 : W3/W4 (B+/B−) et W5/W6 (OUT+/OUT−) sont deux paires rouge/noir soudées côte à côte. " + "1 bague de gaine thermo à chaque bout de W3/W4, 2 bagues sur W5/W6 : une inversion contourne la protection DW01.", + "JST-XH pré-sertis : vérifier que le rouge est sur la broche 1 des deux côtés (M et F) avant de souder.", + "Section 22–24 AWG (plus petit numéro = plus gros fil ; le JST-XH accepte 22 à 28 AWG) : W1-W4 portent le courant de charge (1 A par défaut).", + "Longueurs : calculées sur le modèle, chapeau soulevé de 50 mm, avec 15 à 25 mm de marge ; à ajuster au montage.", +] + +# --- Mise en page ------------------------------------------------------------------------ +DPI = 300 +W, H = 2480, 3508 # A4 à 300 dpi +M = 150 # marge +FONT_DIR = next((d for d in (Path("/usr/share/fonts/truetype/dejavu"), Path("C:/Windows/Fonts")) + if (d / "DejaVuSans.ttf").exists()), Path("/usr/share/fonts/truetype/dejavu")) + + +def font(size, bold=False): + return ImageFont.truetype(str(FONT_DIR / ("DejaVuSans-Bold.ttf" if bold else "DejaVuSans.ttf")), size) + + +F_TITLE, F_H2, F_TXT, F_SMALL, F_BOLD = font(64, True), font(44, True), font(32), font(30), font(32, True) +# 30 px à 300 dpi = 7,2 pt : plus petite police utilisée + + +def wrap(d, text, f, width): + lines, cur = [], "" + for w in text.split(): + t = (cur + " " + w).strip() + if d.textlength(t, font=f) <= width: + cur = t + else: + if cur: + lines.append(cur) + cur = w + if cur: + lines.append(cur) + return lines + + +def lh(f): + return int(f.size * 1.3) + + +class Doc: + def __init__(self): + self.pages = [] + self.new_page() + + def new_page(self): + self.img = Image.new("RGB", (W, H), "white") + self.d = ImageDraw.Draw(self.img) + self.pages.append(self.img) + self.y = M + + def need(self, h): + if self.y + h > H - M: + self.new_page() + + +def diagram(doc): + d = doc.d + y0 = doc.y + P1, P2 = y0 + 360, y0 + 650 # lignes des fils + et − + # zones pied / chapeau + xs = 1080 + d.rectangle([M, y0, xs - 20, y0 + 1000], outline=(120, 120, 120), width=3) + d.rectangle([xs + 20, y0, W - M, y0 + 1000], outline=(120, 120, 120), width=3) + d.text((M + 20, y0 + 15), "PIED ET CHAMBRE (berceau de l'accu)", font=F_BOLD, fill=(90, 90, 90)) + d.text((xs + 40, y0 + 15), "CHAPEAU (au-dessus du disque à lamelles)", font=F_BOLD, fill=(90, 90, 90)) + + pts = {} + # berceau 18650 (vertical, + en haut) + cx0, cx1, cy0, cy1 = 230, 390, P1 - 90, P2 + 150 + d.rounded_rectangle([cx0, cy0, cx1, cy1], radius=30, outline="black", width=5, fill=(235, 235, 225)) + d.text(((cx0 + cx1) / 2, (cy0 + cy1) / 2), "18650", font=F_BOLD, fill="black", anchor="mm") + for y, s in ((P1, "+"), (P2 + 60, "−")): + d.rectangle([cx1, y - 15, cx1 + 40, y + 15], fill=(190, 190, 190), outline="black", width=3) + d.text((cx1 - 20, y), s, font=F_H2, fill="black", anchor="rm") + d.text(((cx0 + cx1) / 2, cy0 - 75), "berceau", font=F_SMALL, fill="black", anchor="mb") + d.text(((cx0 + cx1) / 2, cy0 - 35), "(languettes)", font=F_SMALL, fill="black", anchor="mb") + pts["bat+"], pts["bat-"] = (cx1 + 40, P1), (cx1 + 40, P2 + 60) + + # paire JST-XH 2 broches : F (côté berceau) | M (côté chapeau) + jx = 880 + d.rectangle([jx - 70, P1 - 50, jx, P2 + 50], outline="black", width=4, fill=(250, 250, 250)) + d.rectangle([jx, P1 - 50, jx + 70, P2 + 50], outline="black", width=4, fill=(220, 220, 220)) + for i, y in enumerate((P1, P2), 1): + d.text((jx, y), str(i), font=F_BOLD, fill="black", anchor="mm") + d.text((jx, P2 + 120), "JST-XH", font=F_BOLD, fill="black", anchor="mt") + d.text((jx, P2 + 120 + lh(F_BOLD)), "2p", font=F_BOLD, fill="black", anchor="mt") + d.text((jx - 35, P2 + 70), "F", font=F_BOLD, fill="black", anchor="mt") + d.text((jx + 35, P2 + 70), "M", font=F_BOLD, fill="black", anchor="mt") + pts["jstF1"], pts["jstF2"] = (jx - 70, P1), (jx - 70, P2) + pts["jstM1"], pts["jstM2"] = (jx + 70, P1), (jx + 70, P2) + + # TP4056 + tx0, tx1 = 1380, 1620 + d.rectangle([tx0, P1 - 100, tx1, P2 + 100], outline="black", width=5, fill=(30, 80, 160)) + d.text(((tx0 + tx1) / 2, (P1 + P2) / 2 - 40), "TP4056", font=F_BOLD, fill="white", anchor="mm") + d.text(((tx0 + tx1) / 2, (P1 + P2) / 2 + 15), "micro-USB", font=F_SMALL, fill="white", anchor="mm") + d.text(((tx0 + tx1) / 2, (P1 + P2) / 2 + 55), "vers le bas", font=F_SMALL, fill="white", anchor="mm") + for x, y, name, key, anc in ((tx0, P1, "B+", "tpB+", "lm"), (tx0, P2, "B−", "tpB-", "lm"), + (tx1, P1, "OUT+", "tpO+", "rm"), (tx1, P2, "OUT−", "tpO-", "rm")): + d.rectangle([x - 14, y - 14, x + 14, y + 14], fill=(210, 170, 60), outline="black", width=2) + d.text((x + (22 if anc == "lm" else -22), y), name, font=F_SMALL, fill="white", anchor=anc) + pts[key] = (x, y) + + # carte v2.2 et bornier J13 + bx0, bx1 = 2020, W - M - 30 + d.rectangle([bx0, P1 - 120, bx1, P2 + 160], outline="black", width=4, fill=(30, 110, 60)) + d.text(((bx0 + bx1) / 2, P2 + 140), "carte v2.2", font=F_BOLD, fill="white", anchor="mb") + jx0, jx1 = bx0 + 10, bx0 + 110 + d.rectangle([jx0, P1 - 60, jx1, P2 + 60], outline="black", width=4, fill=(40, 120, 200)) + for i, y in enumerate((P1, P2), 1): + d.ellipse([jx0 + 25, y - 25, jx0 + 75, y + 25], fill=(200, 200, 200), outline="black", width=3) + d.line([jx0 + 33, y - 17, jx0 + 67, y + 17], fill="black", width=4) + d.text((jx1 + 15, y), str(i), font=F_BOLD, fill="white", anchor="lm") + pts["j13-%d" % i] = (jx0, y) + d.text(((bx0 + bx1) / 2, P1 - 70), "J13 RAW IN", font=F_BOLD, fill="white", anchor="mb") + d.text(((bx0 + bx1) / 2, P2 + 175), "J13 : bornier à vis 5,08", font=F_SMALL, fill="black", anchor="mt") + + # fils + étiquettes + for wid, col, sig, a, _, a_end, b, _, b_end, length, awg in WIRES: + (xa, ya), (xb, yb) = pts[a], pts[b] + rgb = COLORS[col] + if ya == yb: + d.line([xa, ya, xb, yb], fill=rgb, width=10) + xm, ym = (xa + xb) / 2, ya + else: # W2 : horizontal en bas, puis remonte à la broche + xk = xb - 60 + d.line([(xa, ya), (xk, ya), (xk, yb), (xb, yb)], fill=rgb, width=10, joint="curve") + xm, ym = (xa + xk) / 2, ya + below = sig in ("B−", "GND") + lines = ["%s %s %s" % (wid, col, sig), "%d mm · %s" % (length, awg), a_end + " →", b_end] + fl = [F_BOLD, F_SMALL, F_SMALL, F_SMALL] + tw = max(d.textlength(t, font=f) for t, f in zip(lines, fl)) + th = sum(lh(f) for f in fl) + ty = ym + 22 if below else ym - 22 - th + d.rectangle([xm - tw / 2 - 12, ty - 6, xm + tw / 2 + 12, ty + th], fill="white", outline=rgb, width=3) + for t, f in zip(lines, fl): + d.text((xm, ty), t, font=f, fill=rgb if f is F_BOLD else "black", anchor="mt") + ty += lh(f) + doc.y = y0 + 1000 + 60 + + +def table(doc): + d = doc.d + cols = [("#", 80), ("Couleur", 160), ("Signal", 150), ("De (extrémité)", 660), + ("Vers (extrémité)", 660), ("Longueur", 210), ("Section", 0)] + cols[-1] = (cols[-1][0], W - 2 * M - sum(w for _, w in cols[:-1])) + rows = [[wid, col, sig, "%s — %s" % (a_txt, a_end), "%s — %s" % (b_txt, b_end), "%d mm" % length, awg] + for wid, col, sig, _, a_txt, a_end, _, b_txt, b_end, length, awg in WIRES] + pad = 14 + + def row(cells, f, fill=None, colour=None): + wrapped = [wrap(d, c, f, w - 2 * pad) for c, (_, w) in zip(cells, cols)] + h = max(len(w) for w in wrapped) * lh(f) + 2 * pad + doc.need(h) + x = M + for i, (lines, (_, w)) in enumerate(zip(wrapped, cols)): + doc.d.rectangle([x, doc.y, x + w, doc.y + h], outline="black", width=2, fill=fill) + for k, t in enumerate(lines): + c = colour if (colour and i == 1) else "black" + doc.d.text((x + pad, doc.y + pad + k * lh(f)), t, font=f, fill=c) + x += w + doc.y += h + + doc.need(lh(F_H2) + 200) + doc.d.text((M, doc.y), "Liste des fils", font=F_H2, fill="black") + doc.y += lh(F_H2) + 10 + row([c for c, _ in cols], F_BOLD, fill=(225, 225, 225)) + for r, w in zip(rows, WIRES): + row(r, F_SMALL, colour=COLORS[w[1]]) + doc.y += 50 + + +def notes(doc): + doc.need(lh(F_H2) + 3 * lh(F_TXT)) + doc.d.text((M, doc.y), "Notes", font=F_H2, fill="black") + doc.y += lh(F_H2) + 10 + for n in NOTES: + lines = wrap(doc.d, n, F_TXT, W - 2 * M - 50) + doc.need(len(lines) * lh(F_TXT) + 15) + doc.d.text((M, doc.y), "•", font=F_TXT, fill="black") + for t in lines: + doc.d.text((M + 50, doc.y), t, font=F_TXT, fill="black") + doc.y += lh(F_TXT) + doc.y += 15 + + +def main(): + doc = Doc() + d = doc.d + d.text((M, doc.y), "Faisceau accu — TinySensor « champignon »", font=F_TITLE, fill="black") + doc.y += lh(F_TITLE) + d.text((M, doc.y), "18650 (berceau) → JST-XH → TP4056 → J13 « RAW IN » de la carte v2.2 ; " + "rouge = +, noir = −", font=F_TXT, fill=(70, 70, 70)) + doc.y += lh(F_TXT) + 40 + diagram(doc) + table(doc) + notes(doc) + doc.pages[0].save(OUT, "PDF", resolution=DPI, save_all=True, append_images=doc.pages[1:], + title="Faisceau accu — TinySensor champignon", + creationDate=None, modDate=None) # pas de date : sortie reproductible + print("%s : %d page(s)" % (OUT, len(doc.pages))) + + +if __name__ == "__main__": + main() diff --git a/openscad/mushroom.scad b/openscad/mushroom.scad new file mode 100644 index 0000000..7c0629c --- /dev/null +++ b/openscad/mushroom.scad @@ -0,0 +1,649 @@ +// TinySensor « champignon » — boîtier décoratif (amanite), en plus du boîtier mural. +// Unités : mm. Matière : PETG. +// +// Le chapeau est un tout (dôme + lamelles) posé sur le pied avec une légère inclinaison ; +// toute l'électronique est dans le chapeau, le pied ne porte que l'accu. +// +// cap : dôme en gourde (croquis), bord ondulé ; 3 consoles intérieures au-dessus du disque, vis M3 par en dessous (autotaraudées) +// gills : disque à lamelles = dessous du chapeau ; fentes de ventilation, prise USB, fourreau sur le pied +// chassis : cadre imprimé debout, vissé sur le disque : chambre du bas ventilée (BME280 enfiché sous la carte), +// plancher, carte v2.2 découpée, glissière du module de charge (prise vers le bas, entre deux lamelles) +// deck : plancher percé entre la chambre et la carte, fenêtre du connecteur I2C (barrette femelle traversante) +// stem : pied creux (berceau 18650 vertical), lobé, base bulbeuse, anneau en coupelle, tenon incliné en haut +// ground : disque de 1 mm sous le pied, avec des touffes d'herbe (stabilité + décor) +// cradle : berceau de l'accu, amovible, glissé dans le pied ; deux butées à languette (accu non soudé) +// plug : bouchon de la chambre à lest (sous le berceau), collé dans le lamage du fond +// dot : pois blancs rapportés, collés dans les logements du dôme +// +// Export : python3 update_3mf.py met à jour les pièces dans le projet Orca print/mushroom.3mf (plaques gardées). +// À la main (toutes les pièces dans leur sens d'impression) : +// openscad -D 'part="cap"' -o mushroom_cap.3mf mushroom.scad # bord sur le plateau, sans support +// openscad -D 'part="gills"' -o mushroom_gills.3mf mushroom.scad # lamelles vers le haut +// openscad -D 'part="chassis"' -o mushroom_chassis.3mf mushroom.scad +// openscad -D 'part="deck"' -o mushroom_deck.3mf mushroom.scad +// openscad -D 'part="stem"' -o mushroom_stem.3mf mushroom.scad +// openscad -D 'part="ground"' -o mushroom_ground.3mf mushroom.scad # sol + herbe, en vert ; pied collé dessus +// openscad -D 'part="cradle"' -o mushroom_cradle.3mf mushroom.scad # à plat, méplat sur le plateau +// openscad -D 'part="plug"' -o mushroom_plug.3mf mushroom.scad +// openscad -D 'part="dot"' -D 'dot_d=8' -o mushroom_dot8.3mf mushroom.scad # x n, voir l'écho +// Rendus : +// openscad --preview -D 'part="assembly"' --camera=10,0,145,75,0,25,950 --imgsize=1000,1300 -o rendu_champignon.png mushroom.scad +// openscad --preview -D 'part="assembly"' --camera=0,0,90,112,0,200,720 --imgsize=1000,1000 -o rendu_champignon_dessous.png mushroom.scad +// openscad --preview -D 'part="assembly"' --camera=0,0,30,65,0,40,260 --imgsize=1000,800 -o rendu_champignon_pied.png mushroom.scad +// openscad --preview -D 'part="section"' --camera=0,0,150,90,0,0,850 --imgsize=1000,1300 -o rendu_champignon_coupe.png mushroom.scad +// openscad --preview -D 'part="sensor"' --camera=-18,5,166,96,0,0,150 --imgsize=1200,800 -o rendu_champignon_capteur.png mushroom.scad +// openscad --preview -D 'part="console"' --camera=56,0,5,75,0,-20,95 --imgsize=1200,800 -o rendu_champignon_console.png mushroom.scad # ~4 min +// +// Repères : monde = Z vers le haut, axe du pied en X = Y = 0, Z = 0 sur la table. +// Chapeau (cap_xf) : Z = 0 sous le disque à lamelles, axe incliné de cap_tilt vers +X. +// Dôme : Z = 0 à son bord, cap_skirt sous le disque. Le haut de la gourde est centré sur J1. + +use +use + +/* [Rendu] */ +part = "assembly"; // [assembly, section, sensor, console, cap, gills, chassis, deck, stem, ground, cradle, plug, dot] +dot_d = 8; // diamètre du pois exporté (part = "dot") +cap_color = "#c0392b"; // rendu uniquement (tue-mouches ; panthère : "#8b5a2b") + +/* [Carte v2.2 découpée — voir pcb.scad] */ +crop_w = 60.02; +crop_h = 38.974; +pcb_t = 1.6; +pcb_holes = [[26.1243, -15.6770], [-25.9902, -15.5130]]; // MH5, MH1 du haut, carte tournée de 180° +k_crop_c = [177.01008, 67.51316]; // centre de la découpe (KiCad) : local = [x - cx, cy - y] +i2c_k = [199.35016, 77.11032]; // empreinte « I2C » (JST-EH 6, pas 2.50), broche 1 ; broches vers +X une fois la carte tournée +radio_k = [192.99936, 56.86044]; // J1 « RADIO » (1x3, module 433 MHz debout) : le sommet du dôme est à son aplomb + +/* [Chapeau] */ +cap_tilt = 6; // inclinaison du chapeau sur le pied +cap_R = 72; // rayon maxi moyen du chapeau (bas de la gourde) +cap_H = 190; // hauteur du dôme au-dessus de son bord +// méridien de la gourde, d'après le croquis : [rayon / cap_R, hauteur / cap_H], du bord au sommet (hauteurs croissantes) +cap_shape = [[0.86, 0], [0.97, 0.08], [1, 0.16], [0.93, 0.29], [0.68, 0.41], [0.45, 0.5], [0.38, 0.58], + [0.43, 0.71], [0.5, 0.84], [0.45, 0.94], [0.25, 0.99], [0, 1]]; +cap_lean = 8; // le haut penche vers -X (contre cap_tilt), en pivotant là où le chapeau se resserre +cap_neck = [0.4, 0.62]; // col : déport (calculé pour le sommet sur J1) et penchement s'installent entre ces deux hauteurs (/ cap_H) +cap_t = 2.4; +vent_d = 5; // évent : du haut du plafond vers le flanc, en descendant (la pluie n'entre pas, l'air chaud sort) +vent_at = [90, 72]; // sortie de l'évent sur la surface : (phi, t) de cap_pt, côté arrière +vent_clear = 14; // pas de pois à moins de cette distance de la sortie +win_h = 22; // fenêtres du châssis, en haut de la chambre : l'air passe devant le BME280 +rim_w = 6; // largeur du bord sur le plateau : le bas de la paroi s'épaissit vers l'intérieur (accroche, chapeau très haut) +cap_skirt = 4; // le bord du dôme descend sous le disque (cache sa tranche ; le disque doit y passer) +disc_fit = 0.4; // jeu au rayon entre le disque et le plus étroit de la jupe du dôme +corbel_w = 12; // consoles du dôme, au-dessus du disque : largeur (autour de la vis) +corbel_h = 10; // hauteur de la face droite, chanfrein à 45° au-dessus jusqu'à la paroi +corbel_in = 5; // débord de la console au-delà de l'axe de la vis, vers l'intérieur +corbel_pilot = 8; // profondeur du trou pilote (M3x10 autotaraudeuse : 3 de disque + 7 en prise) +tab_base = [90, 210, 330]; // vis du dôme (consoles) : chacune glisse (±40°) vers le lobe le plus proche +dot_sizes = [6, 8, 10]; +dot_bag = [6, 6, 6, 8, 8, 8, 10, 10, 10, 10, 10]; // tirage de la taille des pois : gros favorisés +dot_pocket = 0.8; +dot_gap = 12; // espace mini entre deux pois + +/* [Contour du chapeau : générateur de lobes] */ +lobe_seed = 6; // graine : changer pour une autre forme +lobe_n = 4.5; // nombre de lobes dominant +lobe_amp = 0.22; // écart maxi au rayon moyen, au bord (0.22 = ±22 %) +lobe_top = 0.3; // part de la déformation qui reste au sommet (1 = bosselé jusqu'en haut) +lobe_rot = 165; // orientation du contour en degrés ; -1 = automatique (meilleur jeu autour de l'électronique) +clear_m = 3; // jeu mini visé entre l'électronique et le dôme (indice de jeu) + +/* [Lamelles] */ +disc_t = 3; +collar_h = 20; // fourreau sous le disque, reçoit le tenon du pied +collar_r = 22.5; // rayon extérieur du fourreau (= haut du pied) +tenon_r = 19.5; +disc_bore = 18.5; // trou central du disque : le haut du berceau y passe, en biais de cap_tilt +n_gills = 48; +gill_t = 1.2; +gill_depth = [17, 3]; // profondeur des lamelles près du pied / au bord + +/* [Châssis] */ +chamber_h = 48; // chambre du bas : BME280 suspendu sous la carte, haut du berceau et paire JST-XH +deck_t = 1.6; +pcb_gap = 5; // plancher -> dessous de la carte (broches traversantes) +ch_wall = 2; +bme_w = 15; // breakout BMx280 15 x 11, 6 broches VIN GND SCL SDA CSB SDO centrées sur la largeur +bme_d = 11; +bme_t = 1.6; +bme_pins = 6; +bme_edge = 1.3; // rangée de broches -> bord du breakout (corps vers +Y) +hdr_p = 2.54; // barrettes au pas de 2.54 +fh_n = 6; // barrette femelle sous la carte : 1 SENSOR_VCC, 2 GND, 3 SCL, 4 SDA, 5 NC (CSB), 6 GND (SDO -> 0x76) +fh_h = 8.5; // hauteur de la femelle sous la carte +mh_h = 2.5; // plastique de la barrette mâle du breakout +mh_pin = 6; // broches mâles au-dessus du plastique (dessinées si fh_n < bme_pins) +fh_clr = 1; // jeu de la fenêtre du plancher autour des barrettes +chg_w = 17.15; // module de charge TP4056 protégé, mesuré (Tars) : 28.7 prise et ergots de découpe compris +chg_l = 28.7; +chg_t = 0.9; +chg_x = 40; // plan de la carte du module +usb_cut = [8.5, 12]; + +/* [Pied] */ +stem_h = 95; // centre du haut du pied (sous le fourreau) +bore_r = 14.7; // logement de l'accu (Ø29.4, reçoit le berceau) +foot_t = 3; // fond du pied +ballast_h = 52; // chambre à lest sous le berceau : hauteur au rayon du logement, plafond conique à 45° +ballast_wall = 2.4; // paroi entre la chambre et l'extérieur du pied +fill_d = 15; // trou de remplissage dans le fond +plug_d = 21; // lamage du bouchon collé +plug_t = 1.2; // = profondeur du lamage (6 couches de 0.2) +stem_r = 19; // rayon du fût en bas, il s'élargit régulièrement jusqu'au fourreau (collar_r) +stem_amp = 0.15; // lobes du fût (n'ajoutent que de la matière : la paroi autour de l'accu ne s'amincit pas) +stem_twist = 1.2; // rotation des lobes le long du fût (°/mm) +bulb_r = [22, 34]; // base bulbeuse (croquis) : saillie, petit côté puis grand côté +bulb_z = 22; // hauteur du ventre +bulb_up = [21, 27]; // étalement du bulbe vers le haut : petit côté, grand côté (épaule) +bulb_dn = 34; // étalement vers le bas : base arrondie, pente < 45° pour l'impression +foot_dir = 200; // direction du grand côté (°) +ring_z = 72; // anneau en coupelle : attache sur le col, bord relevé sous le chapeau +ring_ext = 20; // débord moyen de la coupelle +ring_rise = 0.75; // montée du bord / débord (< 1 : 45° au plus, imprimable sans support) +ring_amp = 0.35; // ondulation de la coupelle (débord et montée) + +/* [Berceau de l'accu] */ +cell_d = 18.6; // 18650 nue, téton plat (protégée / à bouton : jusqu'à ~70) +cell_l = 65; +cr_fit = 0.4; // jeu berceau / logement (au rayon) +cell_up = 2; // déport de l'accu vers le dessus ouvert (épaisseur du fond sous l'accu) +bt_w = 13; // butées à languette : plaque 13 x 11, languette 3 x 7 (percée) +bt_h = 11; +bt_t = 0.4; // épaisseur de la tôle (à mesurer) +bt_tab = [3, 7]; +bt_dimple = 1.8; // bossage de la butée « + » +spring_c = 4; // ressort de la butée « − » comprimé (10.7 libre) +bt_slot = 0.8; // rainure de la plaque +bt_win = 10.5; // fenêtre devant la plaque (passage du ressort / du bossage) + +/* [Sol] */ +ground_t = 1; // disque sous le pied (pièce séparée, à coller) +ground_R = 72; // rayon moyen +ground_amp = 0.15; +grass_seed = 11; +grass_tufts = 4; // touffes de grands brins autour du pied +grass_single = 30; // brins isolés +fringe_h = [3, 9]; // frange d'herbe en zigzag autour du pied : hauteur des dents +fringe_n = 64; // nombre de dents de la frange + +/* [Résolution des maillages] */ +na = 90; // dôme : pas angulaire +nt = 120; // dôme : pas le long du méridien +ns = 72; // pied, corolle : pas angulaire + +/* [Visserie] */ +m3_pilot = 2.5; +m3_clear = 3.4; +m25_pilot = 2.1; + +$fn = 64; +eps = 0.01; + +// ---------- dérivées ---------- +bore_z = foot_t + ballast_h + bore_r + 2; // fond du logement du berceau, au-dessus du cône +ballast_top = foot_t + ballast_h + bore_r; // sommet du plafond +ix = crop_w / 2 + 0.4; // demi-dimensions intérieures du châssis +iy = crop_h / 2 + 0.4; +ch_z = disc_t; // bas du châssis +deck_z = ch_z + chamber_h; // dessus des appuis du plancher +pcb_z = deck_z + deck_t + pcb_gap; // dessous de la carte +ch_h = pcb_z - ch_z; +// carte tournée de 180° dans le châssis : J1 passe du côté -X, sous le haut penché du chapeau +i2c_p1 = [k_crop_c[0] - i2c_k[0], i2c_k[1] - k_crop_c[1]]; // broche 1 dans le repère du chapeau +radio_p = [k_crop_c[0] - radio_k[0], radio_k[1] - k_crop_c[1]]; // J1, idem (= repère du dôme en XY) +cap_top = [(radio_p[0] + (1 - cap_neck[0]) * cap_H * sin(cap_lean)) / cos(cap_lean), radio_p[1]]; // déport qui met le sommet penché sur J1 +bme_z = pcb_z - fh_h - mh_h; // dessus du breakout + +module cap_xf() { translate([0, 0, stem_h]) rotate([0, cap_tilt, 0]) translate([0, 0, collar_h]) children(); } + +// ---------- dôme ---------- +// méridien : Catmull-Rom par cap_shape ; points fantômes miroirs = tangente verticale au bord, horizontale au sommet +cap_prof = [for (p = cap_shape) [p[0] * cap_R, p[1] * cap_H]]; +prof_n = len(cap_prof) - 1; +function ctrl(i) = i < 0 ? [cap_prof[1][0], -cap_prof[1][1]] + : i > prof_n ? [-cap_prof[prof_n - 1][0], cap_prof[prof_n - 1][1]] : cap_prof[i]; +// [point, dérivée] du méridien en u, de 0 (bord) à 1 (sommet) +function prof(u) = let(x = u * prof_n, i = min(prof_n - 1, floor(x)), v = x - i, + a = ctrl(i - 1), b = ctrl(i), c = ctrl(i + 1), d = ctrl(i + 2), + k1 = c - a, k2 = 2 * a - 5 * b + 4 * c - d, k3 = 3 * b - a - 3 * c + d) + [b + (k1 + (k2 + k3 * v) * v) * v / 2, (k1 + (2 * k2 + 3 * k3 * v) * v) / 2]; +prof_zr = [for (i = [0 : 240]) let(p = prof(i / 240)[0]) [p[1], p[0]]]; // table hauteur -> rayon +assert(min([for (i = [1 : 240]) prof_zr[i][0] - prof_zr[i - 1][0]]) > 0, "cap_shape : hauteurs non croissantes"); +// axe du dôme à la hauteur z : part de l'axe du disque, finit en cap_top +function neck_k(z) = let(k = max(0, min(1, (z / cap_H - cap_neck[0]) / (cap_neck[1] - cap_neck[0])))) k * k * (3 - 2 * k); +function cap_shift(z) = cap_top * neck_k(z); +// penchement : rotation autour de la base du col, nulle sous le col (électronique, consoles et disque non concernés) +function lean_pt(p) = let(a = cap_lean * neck_k(p[2]), dz = p[2] - cap_neck[0] * cap_H) + [p[0] * cos(a) - dz * sin(a), p[1], cap_neck[0] * cap_H + p[0] * sin(a) + dz * cos(a)]; + +// contour : harmoniques 2 à 7 à phases aléatoires, pondérées autour de lobe_n, normalisées à lobe_amp +function vsum(v, i = 0) = i < len(v) ? v[i] + vsum(v, i + 1) : 0; +lobe_terms = [for (k = [2 : 7]) let(q = rands(0, 1, 2, lobe_seed * 17 + k)) [k, (0.5 + 0.5 * q[0]) * exp(-pow((k - lobe_n) / 1.5, 2)), 360 * q[1]]]; +function lobe_raw(phi) = vsum([for (h = lobe_terms) h[1] * sin(h[0] * phi + h[2])]); +lobe_peak = max([for (i = [0 : 179]) abs(lobe_raw(2 * i))]); +function lobe_at(phi, rot) = lobe_amp * lobe_raw(phi - rot) / lobe_peak; +// déformation pleine au bord (s = 0), réduite à lobe_top au sommet (s = 1) +function wav_r(phi, s, rot) = 1 + lobe_at(phi, rot) * (lobe_top + (1 - lobe_top) * (1 - s)); +// surépaisseur du bord : pleine sous le disque, puis raccord à 45° (sans support) +function rim_extra(z) = let(e = rim_w - cap_t) max(0, min(e, e - (z - cap_skirt))); +function inner_rr(phi, zz, rot) = lookup(zz, prof_zr) * wav_r(phi, min(1, zz / cap_H), rot) - cap_t - rim_extra(zz); +// jeu : points de l'électronique (repère du chapeau) qui doivent rester à >= 3 mm sous le dôme +clear_pts = concat( + [for (sx = [-1, 1], sy = [-1, 1]) [sx * (crop_w / 2 + 2.4), sy * (crop_h / 2 + 2.4), disc_t + chamber_h + 9]], // châssis + [for (sx = [-1, 1], sy = [-1, 1]) [sx * 30.5, sy * 20, disc_t + chamber_h + 23]], // composants + [for (x = [38, 45], sy = [-1, 1]) [x, sy * 10.5, disc_t + chg_l]]); // chargeur +// v <= 1 : le point est sous le dôme réduit de clear_m +function v_in(p, rot) = let(zz = p[2] + cap_skirt, d = [p[0], p[1]] - cap_shift(zz)) + norm(d) / (inner_rr(atan2(d[1], d[0]), zz, rot) - clear_m); +function worst(rot) = max([for (p = clear_pts) v_in(p, rot)]); +rots = [for (a = [0 : 5 : 355]) worst(a)]; +best_rot = search(min(rots), rots)[0] * 5; +rot = lobe_rot >= 0 ? lobe_rot : best_rot; +function wav(phi, s = 0) = wav_r(phi, s, rot); +// point de la surface : t de 0 (bord) à 90 (sommet) le long du méridien, off = retrait selon la normale +function cap_pt(phi, t, off = 0) = let(m = prof(t / 90), n = [m[1][1], -m[1][0]] / norm(m[1]), s = m[0][1] / cap_H, + e = off > 0 ? rim_extra(m[0][1]) : 0, r = max(0, m[0][0] * wav(phi, s) - off * n[0] - e), z = m[0][1] - off * n[1], c = cap_shift(z)) + lean_pt([r * cos(phi) + c[0], r * sin(phi) + c[1], z]); +// rayon intérieur du dôme à la hauteur zz (repère du dôme) +function inner_r(phi, zz) = inner_rr(phi, zz, rot); +apex = cap_pt(0, 90); // repère du dôme +vent_in = apex - [0, 0, cap_t + 3]; // sous la pointe du plafond conique +vent_out = cap_pt(vent_at[0], vent_at[1]); +assert(vent_out[2] < vent_in[2], "L'évent doit descendre vers l'extérieur"); + +function ring(j, off) = [for (i = [0 : na - 1]) cap_pt(i * 360 / na, j * 90 / nt, off)]; +function surf(off) = concat([for (j = [0 : nt - 1]) each ring(j, off)], [cap_pt(0, 90, off)]); +function o(i, j) = j * na + (i % na); +module dome_solid(off = 0) { // volume plein sous la surface, fermé par le plan du bord + polyhedron(convexity = 6, points = surf(off), faces = concat( + [for (j = [0 : nt - 2], i = [0 : na - 1]) [o(i, j), o(i, j + 1), o(i + 1, j + 1), o(i + 1, j)]], + [for (i = [0 : na - 1]) [o(i, nt - 1), nt * na, o(i + 1, nt - 1)]], + [[for (i = [0 : na - 1]) o(i, 0)]])); +} + +// pois : tirage « disque de Poisson » sur la surface réelle : candidats sur une grille +// (phi, t) dense et bruitée, parcourus dans un ordre pseudo-aléatoire, gardés s'ils sont assez loin des autres +dot_cand = [for (t = [4 : 2 : 88], phi = [0 : 3 : 357]) let(q = rands(0, 1, 3, t * 1000 + phi + lobe_seed)) + let(tt = t + q[0] * 2, pp = phi + q[1] * 3, p = cap_pt(pp, tt)) if (p[2] > 9) + [pp, tt, dot_bag[floor(q[2] * len(dot_bag) * 0.999)], p]]; +function dot_far(c, acc, i = 0) = i >= len(acc) ? true + : norm(c[3] - acc[i][3]) < (c[2] + acc[i][2]) / 2 + dot_gap ? false : dot_far(c, acc, i + 1); +function dot_pick(i, acc) = i >= len(dot_cand) ? acc + : let(c = dot_cand[(i * 7919) % len(dot_cand)]) dot_pick(i + 1, dot_far(c, acc) ? concat(acc, [c]) : acc); +dots = [for (d = dot_pick(0, [])) if (norm(d[3] - vent_out) > vent_clear) d]; // filtrés après le tirage : la répartition des tailles ne bouge pas +function unit(v) = v / norm(v); +function cap_normal(phi, t) = unit(cross(cap_pt(phi + 0.5, t) - cap_pt(phi - 0.5, t), cap_pt(phi, t + 0.5) - cap_pt(phi, t - 0.5))); +module at_dot(d) { + n = cap_normal(d[0], d[1]); + translate(cap_pt(d[0], d[1])) rotate([0, acos(n[2]), atan2(n[1], n[0])]) children(); +} + +// vis du dôme : axe à 5 mm de la paroi au ras du bord (loin de l'électronique), dans les consoles +function tab_r(a) = inner_r(a, 2) - 5; +corbel_z = cap_skirt + disc_t; // face d'appui des consoles = dessus du disque (repère du dôme) +function argmax_a(a0) = let(c = [for (d = [-40 : 2 : 40]) lobe_at(a0 + d, rot)]) a0 - 40 + 2 * search(max(c), c)[0]; +tab_a = [for (a = tab_base) argmax_a(a)]; +// consoles sur la paroi, juste au-dessus du disque : le disque monte dans le dôme et y est tiré par les vis ; +// face droite côté intérieur, chanfrein à 45° jusqu'à la paroi au-dessus ; seule face vers le plateau : +// l'appui (corbel_z, ~7 mm au-dessus du plateau), en pont sans support (vérifié en PETG) +module corbels() { + intersection() { + for (a = tab_a) let(x0 = tab_r(a) - corbel_in, x1 = 1.5 * cap_R) // coupée par la paroi + rotate([0, 0, a]) translate([0, corbel_w / 2, 0]) rotate([90, 0, 0]) linear_extrude(corbel_w) + polygon([[x0, corbel_z], [x1, corbel_z], [x1, corbel_z + corbel_h + x1 - x0], [x0, corbel_z + corbel_h]]); + dome_solid(); + } +} +module cap() { + difference() { + union() { + difference() { + dome_solid(); + // plafond conique à 45° au sommet : le col étroit empêcherait de retirer des supports + intersection() { + translate([0, 0, -eps]) dome_solid(cap_t); + translate([apex[0], apex[1], 0]) cylinder(r1 = apex[2] - cap_t, r2 = 0, h = apex[2] - cap_t); + } + } + corbels(); + } + for (d = dots) at_dot(d) translate([0, 0, -dot_pocket]) cylinder(d = d[2] + 0.3, h = dot_pocket + 1, $fn = 32); + for (a = tab_a) rotate([0, 0, a]) translate([tab_r(a), 0, corbel_z - eps]) cylinder(d = m3_pilot, h = corbel_pilot + eps); + let(v = vent_out - vent_in) translate(vent_in) rotate([0, acos(v[2] / norm(v)), atan2(v[1], v[0])]) cylinder(d = vent_d, h = norm(v) + 5, $fn = 32); + } +} + +module dot(d) { + cylinder(d = d, h = dot_pocket + eps, $fn = 32); // recouvrement : un seul solide avec la lentille + translate([0, 0, dot_pocket]) intersection() { + scale([1, 1, 0.45]) sphere(d = d, $fn = 32); + translate([-d, -d, 0]) cube([2 * d, 2 * d, d]); + } +} + +// ---------- disque à lamelles (repère du chapeau) ---------- +// rayon du disque : le plus étroit de la paroi entre le bord et le dessus du disque (aux creux des lobes, +// la paroi rentre en descendant), pour qu'il monte dans le dôme fermé jusqu'aux consoles +function inner_min(a) = min([for (z = [0 : 0.5 : cap_skirt + disc_t]) inner_r(a, z)]); +function disc_pts() = [for (i = [0 : na - 1]) let(a = i * 360 / na, r = inner_min(a) - disc_fit) + r * [cos(a), sin(a)]]; +disc_rmax = cap_R - cap_t; // normalisation de la profondeur des lamelles (au-delà : profondeur mini) +function gill_d(r) = gill_depth[1] + (gill_depth[0] - gill_depth[1]) * pow(max(0, (disc_rmax - r) / (disc_rmax - collar_r)), 1.3); +function adiff(a, b) = abs(((a - b + 540) % 360) - 180); +// angles où une lamelle gênerait : vis du dôme, prise USB, vis du châssis, vis du fourreau +ch_screws = [for (sx = [-1, 1], sy = [-1, 1]) [sx * (ix - 2.5), sy * (iy - 2.5)]]; +gill_skip = concat([for (a = tab_a) [a, 7]], [[0, 12], [180, 7]], [for (p = ch_screws) [atan2(p[1], p[0]), 5]]); +assert(min([for (a = [-12 : 2 : 12]) inner_min(a) - disc_fit]) >= chg_x + chg_t + 1.5 + usb_cut[0] / 2 + 2, "Disque trop petit autour de la prise USB"); +function gill_ok(a) = min([for (s = gill_skip) adiff(a, s[0]) - s[1]]) > 0; + +module gills() { + difference() { + union() { + linear_extrude(disc_t) polygon(disc_pts()); + // fourreau + translate([0, 0, -collar_h]) cylinder(r = collar_r, h = collar_h + eps); + // lamelles : lames radiales, plus profondes près du pied + intersection() { + for (i = [0 : n_gills - 1]) let(a = (i + 0.5) * 360 / n_gills) if (gill_ok(a)) + rotate([0, 0, a]) rotate([90, 0, 0]) linear_extrude(gill_t, center = true) + polygon(concat([[collar_r - 1, 0]], [for (r = [collar_r - 1 : 2 : cap_R * 1.5]) [r, -gill_d(r)]], [[cap_R * 1.5, 0]])); + translate([0, 0, -50]) linear_extrude(50 + eps) offset(delta = -1) polygon(disc_pts()); + } + } + // fourreau + translate([0, 0, -collar_h - eps]) cylinder(r = tenon_r + 0.25, h = collar_h); + translate([0, 0, -1]) cylinder(r = disc_bore, h = disc_t + 2); + // vis radiale du fourreau (côté -X) + translate([-collar_r - 1, 0, -collar_h + 8]) rotate([0, 90, 0]) cylinder(d = m3_clear, h = collar_r - tenon_r + 2); + // fentes de ventilation sous la chambre du capteur, entre les lamelles ; côté +X aussi (au ras du bord + // du BME280, le reste du breakout est au-dessus du fourreau) + for (i = [0 : n_gills - 1]) let(a = i * 360 / n_gills) + rotate([0, 0, a]) translate([collar_r + 1, -0.8, -1]) cube([min(ix / max(abs(cos(a)), 0.01), iy / max(abs(sin(a)), 0.01), 31) - collar_r - 1, 1.6, disc_t + 2]); + // prise micro-USB + translate([chg_x + chg_t + 1.5 - usb_cut[0] / 2, -usb_cut[1] / 2, -1]) cube([usb_cut[0], usb_cut[1], disc_t + 2]); + // vis du dôme : passage, têtes sous le disque entre les lamelles + for (a = tab_a) rotate([0, 0, a]) translate([tab_r(a), 0, -1]) cylinder(d = m3_clear, h = disc_t + 2); + // vis du châssis, têtes sous le disque + for (p = ch_screws) translate([p[0], p[1], -1]) cylinder(d = m3_clear, h = disc_t + 2); + } +} + +// ---------- châssis (repère du chapeau, posé sur le disque) ---------- +module chassis() { + translate([0, 0, ch_z]) difference() { + union() { + difference() { + translate([-ix - ch_wall, -iy - ch_wall, 0]) cube([2 * (ix + ch_wall), 2 * (iy + ch_wall), ch_h]); + translate([-ix, -iy, -1]) cube([2 * ix, 2 * iy, ch_h + 2]); + // fenêtres de la chambre + for (x = [-20, -6, 8, 22]) translate([x - 4, -iy - ch_wall - 1, chamber_h - 1 - win_h]) cube([8, 2 * (iy + ch_wall) + 2, win_h]); + for (y = [-8, 8]) translate([-ix - ch_wall - 1, y - 4, chamber_h - 1 - win_h]) cube([ch_wall + 2, 8, win_h]); + } + // appuis du plancher et de la carte le long des grands côtés (chanfrein 45° dessous) + for (s = [-1, 1], z = [chamber_h, ch_h]) mirror([0, s < 0 ? 1 : 0, 0]) translate([-ix, iy, z]) + rotate([0, 90, 0]) linear_extrude(2 * ix) polygon([[0, 0], [1.5, 0], [0, -1.5]]); + // coins : bossages des vis du châssis (bas) ; coins hauts pleins jusqu'à la carte (vis M2.5 de la carte) + for (p = ch_screws) translate([p[0] > 0 ? ix - 7 : -ix, p[1] > 0 ? iy - 7 : -iy, 0]) + cube([7, 7, p[1] < 0 ? ch_h : chamber_h - 1]); + // glissière du module de charge, tenue par deux bras sur la paroi +X + translate([chg_x, 0, 0]) rails(chg_w, chg_t, 20); + for (s = [-1, 1]) translate([ix + ch_wall - eps, s > 0 ? chg_w / 2 - 1 : -chg_w / 2 - 2, 0]) cube([chg_x - 1.5 - ix - ch_wall + 2 * eps, 3, 20]); + } + // vis du châssis (par en dessous) et de la carte (par le dessus) + for (p = ch_screws) translate([p[0], p[1], -eps]) cylinder(d = m3_pilot, h = 7); + for (h = pcb_holes) translate([h[0], h[1], ch_h - 6]) cylinder(d = m25_pilot, h = 6 + eps); + } +} + +// deux rails en U face à face, carte dans le plan YZ (épaisseur selon X) +module rails(w, t, h) { + for (s = [-1, 1]) translate([-1.5, s > 0 ? w / 2 - 1 : -w / 2 - 2, 0]) difference() { + cube([t + 3, 3, h]); + translate([1.5 - 0.15, s > 0 ? -eps : 2 - eps, -eps]) cube([t + 0.3, 1 + eps, h + 2 * eps]); + } +} + +// ---------- plancher percé ---------- +module deck() { + difference() { + translate([-ix + 0.3, -iy + 0.3, 0]) cube([2 * ix - 0.6, 2 * iy - 0.6, deck_t]); + for (p = ch_screws) translate([p[0] - 4.6, p[1] - 4.6, -1]) cube([9.2, 9.2, deck_t + 2]); + for (x = [-24 : 6 : 24], y = [-15 : 6 : 15]) translate([x + (round(y / 6) % 2) * 3, y, -1]) cylinder(d = 4, h = deck_t + 2, $fn = 6); + // fenêtre de la barrette femelle I2C + translate([i2c_p1[0], i2c_p1[1], -1]) rotate(180) translate([-(bme_pins - 0.5) * hdr_p - fh_clr, -hdr_p / 2 - fh_clr, 0]) + cube([bme_pins * hdr_p + 2 * fh_clr, hdr_p + 2 * fh_clr, deck_t + 2]); + } +} + +// ---------- pied ---------- +// générateur générique (même principe que le contour du chapeau) : harmoniques 2..7 à phases aléatoires +function hterms(seed, n) = [for (k = [2 : 7]) let(q = rands(0, 1, 2, seed * 17 + k)) [k, (0.5 + 0.5 * q[0]) * exp(-pow((k - n) / 1.5, 2)), 360 * q[1]]]; +function hval(T, phi) = vsum([for (h = T) h[1] * sin(h[0] * phi + h[2])]); +function hpeak(T) = max([for (i = [0 : 179]) abs(hval(T, 2 * i))]); +function hnorm(T, P, phi) = hval(T, phi) / P; // dans [-1, 1] +stem_T = hterms(lobe_seed + 101, 3); stem_P = hpeak(stem_T); +ring_T = hterms(lobe_seed + 202, 4); ring_P = hpeak(ring_T); +ring_T2 = hterms(lobe_seed + 303, 5); ring_P2 = hpeak(ring_T2); +ground_T = hterms(grass_seed, 4); ground_P = hpeak(ground_T); + +function base_r(z) = stem_r + (collar_r - stem_r) * min(1, z / stem_h); +// rayon extérieur du pied : fût conique + lobes tournants (nuls sous le fourreau) + bulbe dissymétrique +function foot_w(phi) = pow((1 + cos(phi - foot_dir)) / 2, 2); // 1 côté grand évasement, 0 à l'opposé +function stem_rr(phi, z) = let(w = foot_w(phi), B = bulb_r[0] + (bulb_r[1] - bulb_r[0]) * w, + S = z < bulb_z ? bulb_dn : bulb_up[0] + (bulb_up[1] - bulb_up[0]) * w, + fade = max(0, min(1, (stem_h - z) / 15))) + base_r(z) * (1 + stem_amp * (0.5 + 0.5 * hnorm(stem_T, stem_P, phi - stem_twist * z)) * fade) + B * exp(-pow((z - bulb_z) / S, 2)); + +// volume de la chambre à lest (cm³), intégré en tranches +ballast_v = let(dz = 0.5, na = 72) vsum([for (z = [foot_t : dz : ballast_top]) vsum([for (i = [0 : na - 1]) + let(r = max(0, min(stem_rr(i * 360 / na, z) - ballast_wall, ballast_top - z))) 0.5 * r * r * (2 * PI / na) * dz])]) / 1000; + +stem_zs = [for (z = [0 : 1.5 : stem_h + 6]) z]; +module stem_body(off = 0, zs = stem_zs) { // off : retrait de la surface (intérieur de la chambre à lest) + J = len(zs); + function v(i, j) = j * ns + (i % ns); + polyhedron(convexity = 4, + points = [for (z = zs, i = [0 : ns - 1]) let(a = i * 360 / ns, r = stem_rr(a, z) - off) [r * cos(a), r * sin(a), z]], + faces = concat([for (j = [0 : J - 2], i = [0 : ns - 1]) [v(i, j), v(i, j + 1), v(i + 1, j + 1), v(i + 1, j)]], + [[for (i = [0 : ns - 1]) v(i, 0)]], [[for (i = [ns - 1 : -1 : 0]) v(i, J - 1)]])); +} + +// anneau en coupelle : part du col et remonte en s'évasant, bord ondulé ; 45° au plus, sans support +module ring_skirt() { + function prof(a) = let(ri = stem_rr(a, ring_z) - 1, ext = ring_ext * (1 + ring_amp * hnorm(ring_T, ring_P, a)), + rise = ext * ring_rise * (1 + 0.3 * ring_amp * hnorm(ring_T2, ring_P2, a))) + [[ri, ring_z + 2.4], [ri + ext, ring_z + rise + 1.2], [ri + ext, ring_z + rise], [ri, ring_z - 1.2]]; + function v(i, k) = (i % ns) * 4 + (k % 4); + polyhedron(convexity = 4, + points = [for (i = [0 : ns - 1], p = prof(i * 360 / ns)) let(a = i * 360 / ns) [p[0] * cos(a), p[0] * sin(a), p[1]]], + faces = [for (i = [0 : ns - 1], k = [0 : 3]) [v(i, k + 1), v(i, k), v(i + 1, k), v(i + 1, k + 1)]]); +} + +module stem() { + difference() { + union() { + // fût, coupé sous le fourreau (plan perpendiculaire à l'axe du chapeau) + intersection() { + stem_body(); + cap_xf() translate([-100, -100, -collar_h - 200]) cube([200, 200, 200]); + } + ring_skirt(); + // tenon incliné, emboîté dans le fourreau + cap_xf() translate([0, 0, -collar_h - 1]) cylinder(r = tenon_r, h = 17); + } + // logement de l'accu, vertical jusqu'en haut (le support passe par le tenon) + translate([0, 0, bore_z]) cylinder(r = bore_r, h = stem_h + 40); + // chambre à lest : suit la peau du pied (paroi ballast_wall, pentes <= 45° comme le bulbe), + // plafond conique à 45° (sans support) ; remplie par le trou du fond puis bouchée + intersection() { + stem_body(ballast_wall, [for (z = [foot_t : 1.5 : ballast_top]) z]); + translate([0, 0, foot_t - eps]) cylinder(r1 = ballast_top - foot_t, r2 = 0, h = ballast_top - foot_t); + } + translate([0, 0, -1]) cylinder(d = fill_d, h = foot_t + 2); + translate([0, 0, -1]) cylinder(d = plug_d + 0.3, h = plug_t + 1); + // vis radiale du fourreau + cap_xf() translate([-tenon_r - 1, 0, -collar_h + 8]) rotate([0, 90, 0]) cylinder(d = m3_pilot, h = 6); + } +} + +// ---------- berceau de l'accu ---------- +// Gouttière imprimée à plat (méplat sur le plateau, ouverte vers le haut), glissée dans le pied par le haut. +// Une butée à languette par bout, glissée dans une rainure ; la languette traverse le fond et ressort +// dans le méplat, où passent aussi les fils (jusqu'au fourreau). Ressort « − » en bas, bossage « + » en haut. +// Repère d'impression : X = axe de l'accu (X = 0 en bas), méplat en Z = 0. +cr_r = bore_r - cr_fit; +cr_flat = cr_r * cos(45); // méplat : flancs à 45° au plus +cr_ax = cr_flat; // axe du logement au-dessus du plateau +cl_z = cr_ax + cell_up; // axe de l'accu +cr_end = 1.6 + bt_slot + 1; // bout : paroi ext. + rainure + paroi int. +cell_x0 = 1.6 + bt_t + spring_c; // bas de l'accu (ressort comprimé) +cr_len = cell_x0 + cell_l + bt_dimple + bt_t + 1.6; +// haut du berceau côté bas du breakout, dans le repère du chapeau (Z = 0 sous le disque) +cradle_top = (bore_z + cr_len - stem_h) * cos(cap_tilt) + cr_r * sin(cap_tilt) - collar_h; +wire_gap = bme_z - bme_t - cradle_top; // place de la paire JST-XH +assert(wire_gap >= 8, str("Pas de place pour la paire JST-XH : ", wire_gap, " mm sous le BME280")); +module cradle() { + difference() { + intersection() { + rotate([0, 90, 0]) translate([-cr_ax, 0, 0]) cylinder(r = cr_r, h = cr_len, $fn = 96); + translate([0, -cr_r, 0]) cube([cr_len, 2 * cr_r, 2 * cr_r]); + } + // logement de l'accu, fente d'insertion au-dessus de son axe (+1 mm) + translate([cr_end, 0, cl_z]) rotate([0, 90, 0]) cylinder(d = cell_d + 0.6, h = cr_len - 2 * cr_end); + translate([cr_end, -cell_d / 2 - 0.3, cl_z + 1]) cube([cr_len - 2 * cr_end, cell_d + 0.6, 2 * cr_r]); + for (s = [0, 1]) { + xs = s == 0 ? 1.6 : cr_len - 1.6 - bt_slot; // rainure + // rainure de la plaque, ouverte en haut ; prolongée par la fente de la languette à travers le fond + translate([xs, -(bt_w + 0.6) / 2, cl_z - bt_h / 2]) cube([bt_slot, bt_w + 0.6, 2 * cr_r]); + translate([xs, -(bt_tab[0] + 0.6) / 2, -1]) cube([bt_slot, bt_tab[0] + 0.6, cl_z]); + // fenêtre devant la plaque + translate([s == 0 ? xs + bt_slot - eps : cr_len - cr_end - eps, -bt_win / 2, cl_z - bt_h / 2]) cube([1 + 2 * eps, bt_win, 2 * cr_r]); + } + // trou de tirage dans le bout du haut (ficelle, nœud à l'intérieur) + translate([cr_len - cr_end - 1, 0, cl_z + cell_d / 2 + 0.8]) rotate([0, 90, 0]) cylinder(d = 2.5, h = cr_end + 2, $fn = 24); + // polarité, gravée sur les faces des bouts + for (m = [[0, "-"], [1, "+"]]) translate([m[0] * cr_len, 0, cl_z]) mirror([m[0], 0, 0]) rotate([90, 0, -90]) + translate([0, 0, -0.6]) linear_extrude(1) text(m[1], size = 8, halign = "center", valign = "center"); + } +} +// berceau monté : bas du berceau sur le fond du logement, méplat vers -X (côté ouvert vers +X) +module cradle_placed() { translate([0, 0, bore_z]) rotate([0, -90, 0]) translate([0, 0, -cr_ax]) children(); } + +// ---------- sol : disque de 1 mm et végétation ---------- +function ground_r(a) = ground_R * (1 + ground_amp * hnorm(ground_T, ground_P, a)); +// brin : lame effilée (0.9 mm d'épaisseur, pointe 0.6 mm), courbée dans son plan, pente <= ~40° (imprimable) +module blade(h, w, lean) { + for (k = [0 : 3]) hull() for (q = [k, k + 1]) + translate([lean * pow(q / 4, 2), 0, h * q / 4]) cube([max(0.6, w * pow(1 - q / 4, 1.3)), 0.9, 0.01], center = true); +} +grass = let(R = rands(0, 1, 6 * (grass_tufts * 9 + grass_single), grass_seed)) + concat( + // touffes : 6 à 9 grands brins en éventail, collés au pied + [for (t = [0 : grass_tufts - 1], b = [0 : 8]) let(o = 6 * (t * 9 + b), t0 = 6 * t * 9, + ta = t * 360 / grass_tufts + 40 * R[t0], tr = stem_rr(ta, 0) + 2 + 4 * R[t0 + 1], + fan = -70 + 140 * b / 8 + 15 * (R[o + 2] - 0.5), h = 10 + 9 * R[o + 3]) + if (b < 6 + 3 * R[t0 + 2]) + [[tr * cos(ta) + 4 * (R[o + 4] - 0.5), tr * sin(ta) + 4 * (R[o + 5] - 0.5)], ta + fan, h, 3 + 1.5 * R[o + 1], h * 0.55 * (R[o] - 0.2)]], + // brins isolés + [for (i = [0 : grass_single - 1]) let(o = 6 * (grass_tufts * 9 + i), a = 360 * R[o], + rr = stem_rr(a, 0) + 4 + (ground_r(a) - 8 - stem_rr(a, 0) - 4) * pow(R[o + 1], 1.5), h = 4 + 6 * R[o + 2]) + [[rr * cos(a), rr * sin(a)], a + 90 * (R[o + 3] - 0.5), h, 2.5 + R[o + 4], h * 0.5 * (R[o + 5] - 0.5)]]); +// frange : dents triangulaires le long d'une courbe ondulée autour du pied (paroi continue de 0.9 mm) +fringe = let(R = rands(0, 1, 3 * fringe_n, grass_seed + 7)) + [for (i = [0 : fringe_n - 1]) let(a = i * 360 / fringe_n, rr = stem_rr(a, 0) + 2.5 + 2 * R[3 * i]) + [rr * cos(a), rr * sin(a), fringe_h[0] + (fringe_h[1] - fringe_h[0]) * R[3 * i + 1], 2 * (R[3 * i + 2] - 0.5)]]; +module fringe_wall() { + for (i = [0 : fringe_n - 1]) let(p = fringe[i], q = fringe[(i + 1) % fringe_n], m = (p + q) / 2, + t = unit([q[0] - p[0], q[1] - p[1], 0])) + hull() { + translate([p[0], p[1], 0]) cylinder(d = 0.9, h = 0.01, $fn = 8); + translate([q[0], q[1], 0]) cylinder(d = 0.9, h = 0.01, $fn = 8); + translate([m[0] + t[0] * p[3] * 1.5, m[1] + t[1] * p[3] * 1.5, p[2]]) cylinder(d = 0.6, h = 0.01, $fn = 8); + } +} +module ground() { + linear_extrude(ground_t) polygon([for (i = [0 : 89]) let(a = i * 4) ground_r(a) * [cos(a), sin(a)]]); + for (g = grass) translate([g[0][0], g[0][1], ground_t - 0.01]) rotate([0, 0, g[1]]) blade(g[2], g[3], g[4]); + translate([0, 0, ground_t - 0.01]) fringe_wall(); +} + +// ---------- maquettes ---------- +module cradle_cell() { // berceau, accu, butées (repère d'impression) + color("#303030") cradle(); + color("#2e6fd8") translate([cell_x0, 0, cl_z]) rotate([0, 90, 0]) cylinder(d = cell_d, h = cell_l); + color("Silver") for (x = [1.6, cr_len - 1.6 - bt_t]) translate([x, -bt_w / 2, cl_z - bt_h / 2]) { + cube([bt_t, bt_w, bt_h]); + translate([0, (bt_w - bt_tab[0]) / 2, -bt_tab[1]]) cube([bt_t, bt_tab[0], bt_tab[1]]); + } +} +module charger() { color("#1a4fa0") translate([0, -chg_w / 2, 0]) cube([chg_t, chg_w, chg_l]); color("Silver") translate([chg_t, -4, 0]) cube([3, 8, 5.5]); } +// BME280 enfiché sous la carte (repère : broche 1 de l'empreinte I2C, dessous de la carte) : +// femelle fh_n positions, plastique de la barrette mâle, breakout parallèle à la carte +module bme() { + color("#222") translate([-(fh_n - 0.5) * hdr_p, -hdr_p / 2, -fh_h]) cube([fh_n * hdr_p, hdr_p, fh_h]); + color("#222") translate([-(bme_pins - 0.5) * hdr_p, -hdr_p / 2, -fh_h - mh_h]) cube([bme_pins * hdr_p, hdr_p, mh_h]); + if (fh_n < bme_pins) color("Gold") for (i = [fh_n : bme_pins - 1]) translate([-i * hdr_p - 0.32, -0.32, -fh_h]) cube([0.64, 0.64, mh_pin]); + color("Purple") translate([-(bme_pins - 1) * hdr_p / 2 - bme_w / 2, -bme_edge, -fh_h - mh_h - bme_t]) cube([bme_w, bme_d, bme_t]); +} + +// coupe radiale par la console d'angle a : console, trou pilote, vis M3 x 10, disque à lamelles +// (render() : faces de coupe propres, sinon z-fighting en aperçu ; ~4 min) +module console_cut(a) { + r = tab_r(a); + module cut() rotate([0, 0, a]) translate([r - 24, 0, -12]) cube([40, 30, 34]); + rotate([0, 0, -a]) { + color("Ivory") render() intersection() { gills(); cut(); } + color(cap_color) render() intersection() { translate([0, 0, -cap_skirt]) cap(); cut(); } + color("Silver") rotate([0, 0, a]) translate([r, 0, 0]) render() intersection() { // autotaraudeuse, tête sous le disque + union() { translate([0, 0, -2]) cylinder(d = 5.5, h = 2, $fn = 32); cylinder(d = 2.9, h = 10, $fn = 32); } + translate([-5, 0, -5]) cube([10, 5, 20]); + } + } +} + +module assembled() { + color("Ivory") stem(); + color("#4a8f3c") translate([0, 0, -ground_t]) ground(); + cradle_placed() cradle_cell(); + cap_xf() { + color("Ivory") gills(); + color("Beige") chassis(); + translate([i2c_p1[0], i2c_p1[1], pcb_z]) rotate(180) bme(); + translate([chg_x + 0.15, 0, ch_z]) charger(); + color("WhiteSmoke") translate([0, 0, deck_z]) deck(); + translate([0, 0, pcb_z]) rotate(180) pcb_v22_cropped(jst_eh = false); + translate([0, 0, -cap_skirt]) { + color(cap_color) cap(); + for (d = dots) at_dot(d) translate([0, 0, -dot_pocket]) color("White") dot(d[2]); + } + } +} + +echo(str("Contour : graine ", lobe_seed, ", orientation ", rot, "°, indice de jeu ", round(worst(rot) * 100) / 100, worst(rot) <= 1 ? " (ok : >= 3 mm partout)" : " (ATTENTION : électronique à moins de 3 mm du dôme, augmenter cap_R / cap_H ou changer de graine)")); +echo(str("Pois : ", len(dots), " au total — ", [for (s = dot_sizes) str("Ø", s, " x", len([for (d = dots) if (d[2] == s) 1]))])); +echo(str("Sommet du dôme : ", [for (v = [apex[0], apex[1]]) round(v * 10) / 10], " (J1 « RADIO » en ", [for (v = radio_p) round(v * 10) / 10], "), ", round(apex[2] - cap_skirt - pcb_z - pcb_t), " mm au-dessus de la carte ; ", round(collar_h * sin(cap_tilt) + apex[0] * cos(cap_tilt) + (apex[2] - cap_skirt) * sin(cap_tilt)), " mm de l'axe du pied en X")); +echo(str("BME280 : broche 1 en ", [for (v = i2c_p1) round(v * 100) / 100], ", dessous du breakout à ", bme_z - bme_t - disc_t, " mm au-dessus du disque")); +echo(str("Hauteur ~", round(stem_h - apex[0] * sin(cap_tilt) + (apex[2] - cap_skirt + collar_h) * cos(cap_tilt) + ground_t), " mm ; chapeau Ø~", round(2 * cap_R), " ; logement accu Ø", 2 * bore_r, " ; berceau ", round(cr_len * 10) / 10, " mm (accu ", cell_l, " mm) ; berceau ", round(cradle_top), " mm au-dessus du dessous du disque, ", round(wire_gap), " mm sous le BME280 ; lest ", round(ballast_v), " cm³ ≈ ", round(ballast_v * 4.7), " g de grenaille acier")); + +if (part == "cap") cap(); +else if (part == "gills") translate([0, 0, disc_t]) mirror([0, 0, 1]) gills(); // lamelles vers le haut +else if (part == "chassis") translate([0, 0, -ch_z]) chassis(); +else if (part == "deck") deck(); +else if (part == "stem") stem(); +else if (part == "ground") ground(); +else if (part == "cradle") cradle(); +else if (part == "plug") cylinder(d = plug_d, h = plug_t); +else if (part == "dot") dot(dot_d); +else if (part == "sensor") difference() { // gros plan : BME280 enfiché sous la carte + assembled(); + translate([-100, -90.5, 70]) cube([200, 90, 150]); // la caméra doit rester hors du volume retiré + translate([-100, -100, 70]) cube([200, 200, 30]); +} +else if (part == "console") console_cut(tab_a[0]); +else if (part == "section") difference() { assembled(); translate([-200, -200, -1]) cube([400, 200, 400]); } +else { + assembled(); + translate([75, -25, 0]) euro2(); +} diff --git a/openscad/pcb.scad b/openscad/pcb.scad new file mode 100644 index 0000000..f666a41 --- /dev/null +++ b/openscad/pcb.scad @@ -0,0 +1,109 @@ +// Représentation simplifiée de la carte TinySensor v2.2 et de son accu 18650, pour les rendus. +// Positions relevées dans hardware/tinysensor/tinySensorv1.kicad_pcb (coordonnées KiCad absolues), +// volumes approximatifs (boîtes et cylindres), pas destinés à l'impression. +// +// Repère : coin bas-gauche du PCB, Y vers le haut (KiCad : Y vers le bas), dessous du PCB en Z = 0. + +pcb_w = 93.98; +pcb_h = 63.754; +pcb_t = 1.6; +k_origin = [113.04016, 48.02632]; // coin haut-gauche du contour Edge.Cuts +k_holes = [[203.75016, 105.51032], [118.04904, 52.81676], [117.61216, 105.43032], [203, 52], [150.88616, 51.83632]]; + +show_battery = true; +// découpe le long des pointillés de sérigraphie (x = 147, y = 87 en KiCad) : on ne garde que +// la partie sans support 18650 ni support CR2032 ; l'accu arrive alors sur J13 « RAW IN » +k_crop = [[147, 48.02632], [207.02016, 87]]; +crop_w = k_crop[1][0] - k_crop[0][0]; +crop_h = k_crop[1][1] - k_crop[0][1]; +// trous restants sur la carte découpée (MH5 et MH1 du haut), coordonnées KiCad +k_crop_holes = [[150.88616, 51.83632], [203, 52]]; + +$fn = 32; + +// Place une empreinte KiCad (position absolue, angle) sur le dessus du PCB. +// Les enfants sont décrits en coordonnées locales de l'empreinte, Y vers le haut (= -Y KiCad). +module kfp(x, y, a = 0) { + translate([x - k_origin[0], pcb_h - (y - k_origin[1]), pcb_t]) rotate([0, 0, a]) children(); +} + +module pins(pts, h = 8) { color("Gold") for (p = pts) translate([p[0] - 0.32, p[1] - 0.32, 0]) cube([0.64, 0.64, h]); } +module header(n, a = 0) { + color("#222") translate([-1.27, -(n - 1) * 2.54 - 1.27, 0]) cube([2.54, n * 2.54, 2.5]); + pins([for (i = [0 : n - 1]) [0, -i * 2.54]]); +} +module radial(d, h, p, c = "#1b3d8f") { color(c) translate([p / 2, 0, 0]) cylinder(d = d, h = h); } +module axial(l = 3.6, d = 1.6, p = 7.62, c = "Tan") { color(c) translate([p / 2 - l / 2, 0, d / 2 + 0.3]) rotate([0, 90, 0]) cylinder(d = d, h = l); } + +module holder_18650() { + // MPD BH-18650-PC2 : fab -2.8..74.8 x -9.85..9.85, bornes à 0 et 72 + color("#202020") { + translate([-2.8, -9.85, 0]) cube([77.6, 19.7, 1.5]); + for (y = [-9.85, 9.85 - 1.5]) translate([-2.8, y, 0]) cube([77.6, 1.5, 7]); + for (x = [-2.8, 74.8 - 4]) translate([x, -9.85, 0]) cube([4, 19.7, 20]); + } + if (show_battery) { + translate([3.4, 0, 1.5 + 9.3]) rotate([0, 90, 0]) { + color("#2e6fd8") cylinder(d = 18.6, h = 64); + color("Silver") translate([0, 0, 64]) cylinder(d = 7, h = 1.2); + } + } +} + +// carte découpée, centrée sur l'origine (dessous du PCB en Z = 0) +module pcb_v22_cropped(jst_eh = true) { // jst_eh = false : JST « I2C » et « I2C_SPI » non posés (champignon) + translate([-(k_crop[0][0] - k_origin[0]) - crop_w / 2, -(pcb_h - (k_crop[1][1] - k_origin[1])) - crop_h / 2, 0]) + intersection() { + pcb_v22(holder = false, jst_eh = jst_eh); + translate([k_crop[0][0] - k_origin[0], pcb_h - (k_crop[1][1] - k_origin[1]), -1]) cube([crop_w, crop_h, 60]); + } +} +// trous de la carte découpée dans le repère de pcb_v22_cropped() +function crop_holes() = [for (h = k_crop_holes) [h[0] - (k_crop[0][0] + k_crop[1][0]) / 2, (k_crop[0][1] + k_crop[1][1]) / 2 - h[1]]]; + +module pcb_v22(holder = true, jst_eh = true) { // holder = false : support 18650 non peuplé + color("#1f7a3a") difference() { + cube([pcb_w, pcb_h, pcb_t]); + for (h = k_holes) translate([h[0] - k_origin[0], pcb_h - (h[1] - k_origin[1]), -1]) cylinder(d = 2.7, h = pcb_t + 2); + } + // alimentation : support 18650 et convertisseur DC/DC + if (holder) kfp(124.72416, 99.08032) holder_18650(); + kfp(147.56966, 58.78882) { + pins([[1.96, 1.54], [4.5, 1.54], [7.04, 1.54]], 6); + color("#1a4fa0") translate([0, 0, 5]) cube([19, 9, 1.2]); + color("#333") translate([11, 2, 6.2]) cube([5, 5, 3]); + } + // ATtiny84A (DIP-14) + kfp(168.92, 70.12, 90) color("#111") translate([0.6, -16.5, 0.5]) cube([6.4, 17.8, 3.3]); + // bouton, condensateurs + kfp(153.53016, 68.09232) { + color("#444") translate([0.25, -5.25, 0]) cube([6, 6, 3.5]); + color("#111") translate([3.25, -2.25, 0]) cylinder(d = 3.5, h = 4.3); + } + kfp(162.67176, 61.74232, 180) radial(5, 11, 2.5); + kfp(194, 70, 180) radial(5, 11, 2.5); + kfp(170.36796, 51.35372) radial(4, 7, 1.5); + kfp(155.92044, 61.60008, 180) radial(4.5, 8, 2.5, "Orange"); + kfp(164.09416, 54.63032, -90) radial(4.5, 8, 2.5, "Orange"); + kfp(165.5, 64, -90) color("#c8a000") translate([-1, -1, 0]) cube([7, 2, 6]); + // résistances, diodes, LED + for (r = [[179.96444, 75.3934, -90], [182.36444, 75.3934, -90], [162.06216, 84.60232, 90], [185.78068, 57.99836, 90], [175.47336, 55.45836, 180]]) + kfp(r[0], r[1], r[2]) axial(); + for (d = [[182.58028, 58.07456, 90], [180.01996, 50.49012, -90]]) kfp(d[0], d[1], d[2]) axial(3.8, 1.9, 7.62, "#8a1c1c"); + kfp(157.99816, 81.55932, 90) color("Red") translate([1.27, 0, 0]) cylinder(d = 1.8, h = 3); + // connecteurs + if (jst_eh) for (y = [83.91032, 77.11032]) kfp(199.35016, y, 180) { + color("WhiteSmoke") translate([-2.5, -2.3, 0]) cube([17.5, 4.0, 6]); + pins([for (i = [0 : 5]) [i * 2.5, 0]], 3); + } + kfp(151.67356, 77.99324, -90) color("#1f6fd0") translate([-2.71, -4, 0]) cube([10.5, 8, 10]); + kfp(164, 85) { + color("#222") translate([3.18, 0.9, 0]) cube([7.62, 5.08, 2.5]); + pins([for (x = [4.45428, 6.99428, 9.53428], y = [2.17488, 4.71488]) [x, y]]); + } + for (h = [[192.99936, 56.86044, 0, 3], [174.17796, 58.59272, -90, 3], [188.9506, 51.181, 90, 3], + [199.75576, 66.79692, 0, 2], [199.71004, 56.61152, 0, 2], [171.20616, 73.68032, -90, 2]]) + kfp(h[0], h[1], h[2]) header(h[3]); +} + +pcb_v22(); diff --git a/openscad/print/box.3mf b/openscad/print/box.3mf new file mode 100644 index 0000000..ad9b026 Binary files /dev/null and b/openscad/print/box.3mf differ diff --git a/openscad/print/mushroom.3mf b/openscad/print/mushroom.3mf new file mode 100644 index 0000000..ae66eb5 Binary files /dev/null and b/openscad/print/mushroom.3mf differ diff --git a/openscad/print/shield.3mf b/openscad/print/shield.3mf new file mode 100644 index 0000000..2ddb721 Binary files /dev/null and b/openscad/print/shield.3mf differ diff --git a/openscad/print/stand.3mf b/openscad/print/stand.3mf new file mode 100644 index 0000000..5e81ea2 Binary files /dev/null and b/openscad/print/stand.3mf differ diff --git a/openscad/rendu.png b/openscad/rendu.png new file mode 100644 index 0000000..3c95de3 Binary files /dev/null and b/openscad/rendu.png differ diff --git a/openscad/rendu_champignon.png b/openscad/rendu_champignon.png new file mode 100644 index 0000000..3222043 Binary files /dev/null and b/openscad/rendu_champignon.png differ diff --git a/openscad/rendu_champignon_berceau.png b/openscad/rendu_champignon_berceau.png new file mode 100644 index 0000000..019ad44 Binary files /dev/null and b/openscad/rendu_champignon_berceau.png differ diff --git a/openscad/rendu_champignon_capteur.png b/openscad/rendu_champignon_capteur.png new file mode 100644 index 0000000..5d44d48 Binary files /dev/null and b/openscad/rendu_champignon_capteur.png differ diff --git a/openscad/rendu_champignon_console.png b/openscad/rendu_champignon_console.png new file mode 100644 index 0000000..1a0090f Binary files /dev/null and b/openscad/rendu_champignon_console.png differ diff --git a/openscad/rendu_champignon_coupe.png b/openscad/rendu_champignon_coupe.png new file mode 100644 index 0000000..06271be Binary files /dev/null and b/openscad/rendu_champignon_coupe.png differ diff --git a/openscad/rendu_champignon_dessous.png b/openscad/rendu_champignon_dessous.png new file mode 100644 index 0000000..9c997ab Binary files /dev/null and b/openscad/rendu_champignon_dessous.png differ diff --git a/openscad/rendu_champignon_pied.png b/openscad/rendu_champignon_pied.png new file mode 100644 index 0000000..7ffabef Binary files /dev/null and b/openscad/rendu_champignon_pied.png differ diff --git a/openscad/rendu_ouvert.png b/openscad/rendu_ouvert.png new file mode 100644 index 0000000..9f95e08 Binary files /dev/null and b/openscad/rendu_ouvert.png differ diff --git a/openscad/rendu_pied.png b/openscad/rendu_pied.png new file mode 100644 index 0000000..b486162 Binary files /dev/null and b/openscad/rendu_pied.png differ diff --git a/openscad/sensor_shield.scad b/openscad/sensor_shield.scad new file mode 100644 index 0000000..8074f1d --- /dev/null +++ b/openscad/sensor_shield.scad @@ -0,0 +1,173 @@ +// Abri de sonde extérieur (type Stevenson à assiettes) pour breakout BMx280 +// Unités : mm. Matière : PETG (blanc de préférence : moins d'échauffement solaire). +// +// Pièces (toutes imprimables sans support, face supérieure sur le plateau) : +// cap : chapeau plein + bras + platine murale x1 +// plate : assiette ajourée x n_plates +// base : fond avec glissière capteur et passe-câble x1 +// clip : (option) adaptateur pour accrocher l'abri sous le boîtier principal x1 +// boulonné derrière la platine murale (2 vis M4x8 tête fraisée + écrous +// noyés), il se clipse sur la patte murale basse du boîtier, qui porte +// deux encoches latérales (tinysensor_box.scad, shield_notch = true). +// Export : +// openscad -D 'part="cap"' -o shield_cap.3mf sensor_shield.scad +// openscad -D 'part="plate"' -o shield_plate.3mf sensor_shield.scad +// openscad -D 'part="base"' -o shield_base.3mf sensor_shield.scad +// +// Assemblage : 3 tiges filetées M3 (ou vis M3 longues) traversant les entretoises, +// écrous sous le fond. Fixer le chapeau au mur AVANT d'empiler les assiettes. + +/* [Rendu] */ +part = "assembly"; // [cap, plate, base, clip, assembly] + +/* [Assiettes] */ +n_plates = 4; // assiettes ajourées entre chapeau et fond +plate_d = 56; // diamètre extérieur +hole_d = 26; // diamètre de la colonne centrale (glissière capteur ~19.4 de large) +plate_t = 1.6; +skirt_h = 5; // jupe : arrête la pluie oblique et le rayonnement +skirt_t = 1.6; +gap = 10; // pas vertical entre deux assiettes (hauteur des entretoises) + +/* [Assemblage] */ +post_r = 20; // rayon du cercle des entretoises +post_d = 7; +rod_d = 3.4; // passage M3 + +/* [Fixation murale] */ +wall_gap = 12; // distance jupe / mur +bracket_w = 40; +bracket_t = 4; +wall_screw_d = 4.5; + +/* [Clip sous le boîtier — doit correspondre à la patte basse de tinysensor_box.scad] */ +ear_t = 4; // épaisseur de la patte (floor_t + 1.6) +ear_w = 20; +notch_y = 8; // encoche depuis le bout de la patte (shield_notch_y) +clip_gap = 6; // jeu vertical entre bout de patte et dessus du chapeau +clip_engage = 16; // longueur de patte prise dans le clip +clip_t = 4; // épaisseur de l'adaptateur +prong_w = 2.4; // largeur des branches (flexion) +lip = 3; // recouvrement de la face avant de la patte +bump_r = 0.8; // bossage qui s'engage dans l'encoche (r = 1) + +/* [Capteur — breakout 15 x 11 mm, PCB 1.6 mm] */ +sensor_w = 15; +sensor_pcb_t = 1.6; +rail_h = 16; +cable_d = 6; + +$fn = 96; +eps = 0.01; + +stack_h = (n_plates + 1) * gap + plate_t; // du dessous du fond au dessus du chapeau +wall_y = plate_d / 2 + wall_gap; // plan du mur (dos de la platine) + +module posts(h) { + for (a = [90, 210, 330]) rotate([0, 0, a]) translate([post_r, 0, 0]) cylinder(d = post_d, h = h); +} +module rod_holes(h) { + for (a = [90, 210, 330]) rotate([0, 0, a]) translate([post_r, 0, -eps]) cylinder(d = rod_d, h = h + 2 * eps); +} + +// Assiette dans son sens d'impression : dessus (usage) sur le plateau, jupe et entretoises vers +Z +module plate_print(solid = false) { + difference() { + union() { + cylinder(d = plate_d, h = plate_t); + difference() { + cylinder(d = plate_d, h = skirt_h); + translate([0, 0, -eps]) cylinder(d = plate_d - 2 * skirt_t, h = skirt_h + 2 * eps); + } + posts(gap); + } + if (!solid) translate([0, 0, -eps]) cylinder(d = hole_d, h = plate_t + 2 * eps); + rod_holes(gap); + } +} + +module cap_print() { + plate_print(solid = true); + // bras vers le mur (sur le plateau) + platine murale verticale (vers +Z = vers le bas en usage) + difference() { + union() { + translate([-bracket_w / 2, 0, 0]) cube([bracket_w, wall_y, plate_t * 2]); + translate([-bracket_w / 2, wall_y - bracket_t, 0]) cube([bracket_w, bracket_t, stack_h - plate_t]); + // goussets + for (x = [-bracket_w / 2, bracket_w / 2 - 3]) + hull() { + translate([x, plate_d / 2 - 2, 0]) cube([3, eps, plate_t * 2]); + translate([x, wall_y - bracket_t, 0]) cube([3, eps, 20]); + } + } + for (z = [12, stack_h - plate_t - 12]) + translate([0, wall_y + eps, z]) rotate([90, 0, 0]) { + cylinder(d = wall_screw_d, h = bracket_t + 2 * eps); + cylinder(d1 = wall_screw_d, d2 = wall_screw_d + 4.4, h = 2.2); // fraisage côté capteur + } + } +} + +// Fond dans son sens d'usage (= sens d'impression) +module base_print() { + difference() { + cylinder(d = plate_d, h = plate_t); + rod_holes(plate_t * 2); + // passe-câble sous le capteur + translate([0, 0, -eps]) cylinder(d = cable_d, h = plate_t * 2); + // drainage + for (a = [30 : 60 : 330]) rotate([0, 0, a]) translate([hole_d / 2 - 4, 0, -eps]) cylinder(d = 3, h = plate_t * 2); + } + // glissière : deux rails en U qui tiennent les bords du breakout, posé verticalement + for (s = [-1, 1]) translate([s * (sensor_w / 2 + 0.2) - (s > 0 ? 0 : 2), -2.5, 0]) + difference() { + cube([2, 5, rail_h]); + translate([s > 0 ? -eps : 1, 2.5 - (sensor_pcb_t + 0.3) / 2, plate_t]) cube([1 + eps, sensor_pcb_t + 0.3, rail_h]); + } +} + + +// Adaptateur de clip, modélisé en coordonnées d'usage puis couché pour l'impression : +// X horizontal, Y vertical (haut), Z sortant du mur (dos sur le mur, Z = 0). +module clip_use() { + y0 = plate_t; // bas de la platine murale + y1 = stack_h; // haut (dessus du chapeau) + ye = y1 + clip_gap; // bout de la patte du boîtier + yt = ye + clip_engage; // haut des branches + zi = ear_t + 0.3; // face intérieure des lèvres + zt = zi + lip; // épaisseur totale des branches (lèvre à 45°) + difference() { + translate([-bracket_w / 2, y0, 0]) cube([bracket_w, y1 - y0, clip_t]); + for (y = [y1 - 12, y0 + 12]) translate([0, y, -eps]) { + cylinder(d = 4.4, h = clip_t + 2 * eps); + cylinder(d = 8.1 + 0.3, h = 3.4, $fn = 6); // écrou M4 noyé côté mur + } + } + for (sx = [-1, 1]) { + xi = sx * (ear_w / 2 + 0.2); // face intérieure de la branche + xo = sx * (ear_w / 2 + 0.2 + prong_w); + // branche + translate([min(xi, xo), y1 - 10, 0]) cube([prong_w, yt - y1 + 10, zt]); + // lèvre en biseau 45° : dessous incliné, imprimable dos au plateau ; + // elle appuie sur l'arête avant de la patte et la plaque contre le mur + hull() { + translate([xi - (sx > 0 ? eps : 0), ye, zi]) cube([eps, clip_engage, zt - zi]); + translate([xi - sx * lip - (sx > 0 ? eps : 0), ye, zt - eps]) cube([eps, clip_engage, eps]); + } + // bossage d'accroche + translate([xi, ye + notch_y, 0]) cylinder(r = bump_r, h = zi); + } +} +module clip_print() { clip_use(); } // dos sur le plateau : Z d'usage = Z d'impression + +if (part == "cap") cap_print(); +else if (part == "plate") plate_print(); +else if (part == "base") base_print(); +else if (part == "clip") clip_print(); +else { + // vue d'ensemble en position d'usage (Z vers le haut) + color("White") base_print(); + for (i = [1 : n_plates]) color("WhiteSmoke") translate([0, 0, i * gap + plate_t]) mirror([0, 0, 1]) plate_print(); + color("Gainsboro") translate([0, 0, (n_plates + 1) * gap + plate_t]) mirror([0, 0, 1]) cap_print(); + color("Purple") translate([-sensor_w / 2, -0.8, plate_t]) cube([sensor_w, 1.6, 11]); +} diff --git a/openscad/stand.scad b/openscad/stand.scad new file mode 100644 index 0000000..bf7830f --- /dev/null +++ b/openscad/stand.scad @@ -0,0 +1,106 @@ +// Pied de table : boîtier + abri de sonde clipsé, debout sur une table. +// Unités : mm. Matière : PETG. +// +// Le mât tient lieu de mur : le boîtier s'y visse par ses deux pattes murales +// (M4 tête fraisée + écrous noyés dans le dos du mât), l'abri reste clipsé sous +// la patte basse comme au mur. Le mât s'emboîte dans la semelle (1 vis M4). +// +// Pièces (imprimables sans support) : +// mast : mât, à plat, dos sur le plateau x1 +// foot : semelle avec douille, dessous sur le plateau x1 +// Export : +// openscad -D 'part="mast"' -o stand_mast.3mf stand.scad +// openscad -D 'part="foot"' -o stand_foot.3mf stand.scad +// +// Repère = celui de tinysensor_box.scad (X droite, Y haut, Z sortant du mur, mur en Z = 0). + +/* [Rendu] */ +part = "assembly"; // [mast, foot, assembly] + +/* [Repris de tinysensor_box.scad / sensor_shield.scad — à garder alignés] */ +cav_w = 95.98; +ear_top = 102.154; // bout de la patte haute (cav_h + wall + flange + ear_l) +ear_holes_y = [96.154, -16.4]; // axes des trous de vis murales des pattes +shield_bottom = -80; // dessous de l'abri clipsé (oy de assembly.scad) +shield_front = 72; // avant de l'abri (wall_y + clip_t + plate_d / 2) + +/* [Mât] */ +mast_w = 30; +mast_t = 6; +under_clr = 30; // dégagement sous l'abri : boucle du câble qui remonte par le fond + +/* [Semelle] */ +foot_w = 130; // largeur (X) +foot_back = 40; // débord derrière le mât +foot_front = 90; // débord devant le mur (l'abri avance jusqu'à shield_front) +foot_t = 4; +foot_r = 10; +socket_h = 25; // profondeur d'emboîtement du mât +socket_wall = 3; +fit = 0.3; // jeu mât / douille +gusset_t = 3; + +/* [Visserie] */ +screw_d = 4.4; // M4 +nut_af = 8.4; // écrou M4 (7.9 sur plats + jeu) +nut_h = 3.4; + +$fn = 48; +eps = 0.01; + +cx = cav_w / 2; +y_bot = shield_bottom - under_clr; // pied du mât = dessus de la semelle +y_floor = y_bot - foot_t; // table +mast_l = ear_top - y_bot; +socket_screw_y = y_bot + socket_h / 2; + +module mast_use() { + difference() { + translate([cx - mast_w / 2, y_bot, -mast_t]) cube([mast_w, mast_l, mast_t]); + // vis des pattes du boîtier, écrous noyés dans le dos + for (y = ear_holes_y) translate([cx, y, -mast_t - eps]) { + cylinder(d = screw_d, h = mast_t + 2 * eps); + cylinder(d = nut_af / cos(30), h = nut_h, $fn = 6); + } + // vis de la douille + translate([cx, socket_screw_y, -mast_t - eps]) cylinder(d = screw_d, h = mast_t + 2 * eps); + } +} + +module foot_use() { + sx = mast_w / 2 + fit + socket_wall; // demi-largeur extérieure de la douille + z0 = -mast_t - fit - socket_wall; // dos de la douille + z1 = fit + socket_wall; // face avant de la douille + difference() { + union() { + // semelle + translate([0, y_floor, 0]) rotate([-90, 0, 0]) linear_extrude(foot_t) + translate([cx - foot_w / 2, -foot_front]) offset(r = foot_r) offset(delta = -foot_r) + square([foot_w, foot_front + mast_t + foot_back]); + // douille + translate([cx - sx, y_bot - eps, z0]) cube([2 * sx, socket_h + eps, z1 - z0]); + // goussets avant / arrière (plan YZ) et latéraux (plan XY) + for (g = [[z1, z1 + 30], [z0, z0 - 25]]) + hull() { + translate([cx - gusset_t / 2, y_bot - eps, g[0]]) cube([gusset_t, socket_h, eps]); + translate([cx - gusset_t / 2, y_bot - eps, g[1]]) cube([gusset_t, eps, eps]); + } + for (s = [-1, 1]) + hull() { + translate([cx + s * sx - (s > 0 ? eps : 0), y_bot - eps, -mast_t / 2 - gusset_t / 2]) cube([eps, socket_h, gusset_t]); + translate([cx + s * (sx + 25) - (s > 0 ? eps : 0), y_bot - eps, -mast_t / 2 - gusset_t / 2]) cube([eps, eps, gusset_t]); + } + } + // logement du mât + translate([cx - mast_w / 2 - fit, y_bot, -mast_t - fit]) cube([mast_w + 2 * fit, socket_h + eps, mast_t + 2 * fit]); + // vis traversante M4 x 20 + écrou + translate([cx, socket_screw_y, z0 - eps]) cylinder(d = screw_d, h = z1 - z0 + 2 * eps); + } +} + +if (part == "mast") translate([0, 0, mast_t]) mast_use(); // dos sur le plateau +else if (part == "foot") translate([0, 0, -y_floor]) rotate([90, 0, 0]) foot_use(); // Y d'usage -> Z +else { + color("Peru") mast_use(); + color("Sienna") foot_use(); +} diff --git a/openscad/tinysensor_box.scad b/openscad/tinysensor_box.scad new file mode 100644 index 0000000..7cfe4d6 --- /dev/null +++ b/openscad/tinysensor_box.scad @@ -0,0 +1,182 @@ +// TinySensor v2.2 — boîtier mural vissé avec joint +// Unités : mm. Matière visée : PETG. +// +// Export : +// openscad -D 'part="base"' -o base.3mf tinysensor_box.scad +// openscad -D 'part="lid"' -o lid.3mf tinysensor_box.scad +// Rendu boîtier ouvert (carte + accu, couvercle retourné à côté) : +// openscad --preview -D 'part="open"' --camera=110,-230,420,110,30,0 --imgsize=1400,1000 -o rendu_ouvert.png tinysensor_box.scad +// +// Repère : origine = coin "haut-gauche" du PCB vu de dessus (comme KiCad), +// X vers la droite, Y vers le bas (KiCad) converti en Y vers le haut ici. +// En pose murale, le côté "bas" (Y=0, côté support 18650) est orienté vers le sol. + +/* [Rendu] */ +part = "assembly"; // [base, lid, assembly, open] +usage = "ext"; // [int, ext] +gland_side = "left"; // [left, right, bottom] paroi du presse-étoupe (usage ext) + +/* [PCB — tiré de hardware/tinysensor/tinySensorv1.kicad_pcb] */ +pcb_w = 93.98; +pcb_h = 63.754; +pcb_t = 1.6; +// Trous de fixation M2.5 (4 coins), coordonnées KiCad relatives au coin du PCB +pcb_holes_kicad = [[89.96, 3.97], [5.01, 4.79], [4.57, 57.40], [90.71, 57.48]]; + +/* [Volumes] */ +clr = 1.0; // jeu PCB / paroi +bay = 14; // baie câble côté bas (presse-étoupe, antenne) +standoff_h = 5; // hauteur sous PCB (pattes THT) +comp_h = 25; // composant le plus haut au-dessus du PCB (gros condensateurs) +top_clr = 2; // jeu sous le couvercle + +/* [Parois] */ +wall = 2.4; // 6 périmètres buse 0.4 +floor_t = 2.4; +lid_t = 3; +r_out = 3; // rayon extérieur des angles + +/* [Joint et vis de couvercle] */ +flange = 8; // largeur du rebord extérieur +flange_t = 5; // épaisseur du rebord +groove_w = 2.2; // gorge pour cordon/joint 2 mm (TPU, mousse EPDM) +groove_d = 1.5; +rib_w = 1.2; // nervure du couvercle qui écrase le joint +rib_h = 1.0; +lid_screw_d_pilot = 2.5; // M3 auto-taraudeuse dans le PETG +lid_screw_d_clear = 3.4; + +/* [Fixations] */ +pcb_screw_d_pilot = 2.1; // M2.5 auto-taraudeuse +standoff_d = 6; +gland_d = 12.5; // PG7 +wall_screw_d = 4.5; // vis murale Ø4 +ear_l = 12; // dépassement de la patte au-delà du rebord +shield_notch = true; // encoches pour clipser l'abri de sonde sous le boîtier +shield_notch_y = 8; // position des encoches depuis le bout de la patte basse + +use +use + +$fn = 48; +eps = 0.01; +// Presse-étoupe latéral : dans la baie (sous le PCB en Y), assez haut pour que +// l'écrou intérieur (PG7, 15 sur plats) passe au-dessus du bord du PCB, et +// sous le chanfrein du rebord qui le protège de la pluie. +gland_z = gland_side == "bottom" ? 11.5 : 15; +gland_x = 22; // position en paroi basse : décalé de la patte murale centrale +gland_y = 8; // position en paroi latérale : milieu de la baie + +// ---------- dimensions dérivées ---------- +cav_w = pcb_w + 2 * clr; +cav_h = pcb_h + 2 * clr + bay; +cav_d = standoff_h + pcb_t + comp_h + top_clr; +base_h = floor_t + cav_d; +pcb_x0 = clr; // coin PCB dans le repère cavité +pcb_y0 = clr + bay; +groove_off = wall + 1.8; // axe de la gorge depuis le bord cavité +screw_off = wall + flange - 2.8; // axe des vis depuis le bord cavité + +function holes() = [for (h = pcb_holes_kicad) [pcb_x0 + h[0], pcb_y0 + pcb_h - h[1]]]; + +module rrect(w, h, r) { + translate([r, r]) offset(r = r) square([w - 2 * r, h - 2 * r]); +} +// rectangle cavité décalé de d (d>0 vers l'extérieur) +module ring_rect(d, r = 1) { + translate([-d, -d]) rrect(cav_w + 2 * d, cav_h + 2 * d, max(r, 0.5)); +} +module groove_path(off, w) { + difference() { ring_rect(off + w / 2, 1 + off); ring_rect(off - w / 2, 1); } +} +function lid_screws() = let(o = screw_off) [ + [-o, -o], [cav_w + o, -o], [-o, cav_h + o], [cav_w + o, cav_h + o], + [cav_w / 2, -o], [cav_w / 2, cav_h + o] +]; + +// ---------- base ---------- +module base() { + difference() { + union() { + // corps + linear_extrude(base_h) ring_rect(wall, r_out); + // rebord supérieur + translate([0, 0, base_h - flange_t]) linear_extrude(flange_t) ring_rect(wall + flange, r_out); + // chanfrein à 45° sous le rebord : imprimable sans support + hull() { + translate([0, 0, base_h - flange_t - flange]) linear_extrude(eps) ring_rect(wall, r_out); + translate([0, 0, base_h - flange_t - eps]) linear_extrude(eps) ring_rect(wall + flange, r_out); + } + // pattes murales (haut et bas) + for (s = [-1, 1]) ear(s); + } + // cavité + translate([0, 0, floor_t]) linear_extrude(cav_d + eps) ring_rect(0, 1); + // gorge du joint + translate([0, 0, base_h - groove_d]) linear_extrude(groove_d + eps) groove_path(groove_off, groove_w); + // trous de vis du couvercle (borgnes) + for (p = lid_screws()) translate([p[0], p[1], base_h - flange_t + 0.8]) cylinder(d = lid_screw_d_pilot, h = flange_t); + // presse-étoupe : paroi latérale (défaut) ou paroi basse + if (usage == "ext") { + if (gland_side == "bottom") + translate([gland_x, -wall - flange - eps, gland_z]) rotate([-90, 0, 0]) cylinder(d = gland_d, h = wall + flange + 2 * eps); + else + translate([gland_side == "left" ? -wall - eps : cav_w - eps, gland_y, gland_z]) rotate([0, 90, 0]) cylinder(d = gland_d, h = wall + 2 * eps); + } + // aérations intérieures (fentes en paroi basse et haute) + if (usage == "int") vents(); + } + // entretoises PCB + for (h = holes()) translate([h[0], h[1], floor_t - eps]) difference() { + cylinder(d = standoff_d, h = standoff_h + eps); + cylinder(d = pcb_screw_d_pilot, h = standoff_h + 1); + } +} + +module ear(s) { + // la patte dépasse du rebord pour laisser passer le tournevis + reach = wall + flange + ear_l; + y = s < 0 ? -reach : cav_h; + hy = s < 0 ? -(wall + flange + ear_l / 2) : cav_h + wall + flange + ear_l / 2; + difference() { + translate([cav_w / 2 - 10, y, 0]) cube([20, reach + eps, floor_t + 1.6]); + translate([cav_w / 2, hy, -eps]) { + cylinder(d = wall_screw_d, h = 10); + translate([0, 0, floor_t + 1.6 - 2.2]) cylinder(d1 = wall_screw_d, d2 = wall_screw_d + 4.4, h = 2.2 + eps); + } + // encoches latérales de la patte basse : accroche du clip de l'abri de sonde (sensor_shield.scad, part="clip") + if (s < 0 && shield_notch) + for (sx = [-1, 1]) translate([cav_w / 2 + sx * 10, -reach + shield_notch_y, -eps]) cylinder(r = 1, h = 10); + } +} + +module vents() { + n = 7; sw = 2; sl = 14; pitch = (cav_w - 20) / (n - 1); + for (yy = [-wall - eps, cav_h - eps]) for (i = [0 : n - 1]) + translate([10 + i * pitch - sw / 2, yy, floor_t + 3]) cube([sw, wall + 2 * eps, sl]); +} + +// ---------- couvercle ---------- +module lid() { + difference() { + linear_extrude(lid_t) ring_rect(wall + flange, r_out); + for (p = lid_screws()) translate([p[0], p[1], -eps]) cylinder(d = lid_screw_d_clear, h = lid_t + 2 * eps); + } + // nervure qui écrase le joint (face intérieure = z<0, retournée à l'impression) + translate([0, 0, -rib_h]) linear_extrude(rib_h + eps) groove_path(groove_off, rib_w); +} + +if (part == "base") base(); +else if (part == "lid") translate([0, 0, lid_t]) mirror([0, 0, 1]) lid(); // face plate sur le plateau, nervure vers le haut +else if (part == "open") { + color("DimGray") base(); + translate([pcb_x0, pcb_y0, floor_t + standoff_h]) pcb_v22(); + // couvercle retourné à côté, face intérieure (nervure) visible + color("Gray") translate([cav_w + 2 * (wall + flange) + 15, 0, lid_t]) mirror([0, 0, 1]) lid(); + translate([cav_w / 2, -wall - flange - 25, 0]) euro2(); +} +else { + color("SteelBlue", 0.9) base(); + color("Orange", 0.6) translate([0, 0, base_h + 8]) lid(); + color("Green", 0.5) translate([pcb_x0, pcb_y0, floor_t + standoff_h]) cube([pcb_w, pcb_h, pcb_t]); +} diff --git a/openscad/update_3mf.py b/openscad/update_3mf.py new file mode 100644 index 0000000..bbfaff5 --- /dev/null +++ b/openscad/update_3mf.py @@ -0,0 +1,294 @@ +#!/usr/bin/env python3 +"""Projets Orca de print/ (un par modèle) mis à jour depuis les .scad, sans toucher aux plaques. + +Usage : python3 update_3mf.py [projet.3mf ...] (par défaut : tous les projets de PROJECTS) + +Projet existant : chaque objet est reconnu par son nom (parts) ; son maillage est réexporté par OpenSCAD et +remplacé sur place, recentré comme le fait Orca, en gardant sa position et son orientation (le bas reste sur +le plateau). Les réglages Orca sont gardés ; ceux de settings ne sont posés que s'ils manquent. +Projet absent : il est créé avec une plaque par entrée de plates, réglages d'imprimante et de filament repris +de TEMPLATE (le projet du champignon, réglé pour l'Ender 3). +""" +import math +import re +import shutil +import subprocess +import sys +import tempfile +import uuid +import xml.etree.ElementTree as ET +import zipfile +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path + +HERE = Path(__file__).resolve().parent +PRINT = HERE / "print" +OPENSCAD = sorted(Path.home().glob("bin/OpenSCAD-*.AppImage"))[-1] +TEMPLATE = PRINT / "mushroom.3mf" +BED = 220 # plateau de l'Ender 3 ; Orca espace ses plaques de 1,2 plateau +GAP = 10 # entre deux pièces d'une même plaque + +# parts : nom dans Orca -> arguments OpenSCAD (pièce dans son sens d'impression) +# plates : [(nom de plaque, [pièces, une entrée par exemplaire])], pour créer le projet +PROJECTS = { + "mushroom.3mf": { + "scad": "mushroom.scad", + "parts": { + "Chapeau": ['part="cap"'], "Lamelles": ['part="gills"'], "Châssis": ['part="chassis"'], + "Plancher": ['part="deck"'], "Pied": ['part="stem"'], "Sol": ['part="ground"'], + "Berceau": ['part="cradle"'], "Bouchon": ['part="plug"'], + "Pois Ø6": ['part="dot"', "dot_d=6"], "Pois Ø8": ['part="dot"', "dot_d=8"], + "Pois Ø10": ['part="dot"', "dot_d=10"], + }, + # chapeau très haut : bord de 6 mm + brim ; sans support (les faces des consoles passent en pont en PETG, + # vérifié à l'impression du 2026-11) + "settings": {"Chapeau": {"brim_type": "outer_only", "brim_width": "10", "sparse_infill_density": "10%"}, + "Pied": {"wall_loops": "6"}}, + "add": {"Bouchon": ("Divers", (300.0, -154.0))}, # ajouté s'il manque, à gauche du plancher et du berceau + # objets d'avant le renommage (« OpenSCAD Model »), reconnus par leur fichier d'origine + "from_file": {"mushroom_cap": "Chapeau", "mushroom_gills": "Lamelles", "mushroom_chassis": "Châssis", + "mushroom_deck": "Plancher", "mushroom_stem": "Pied", "mushroom_ground": "Sol", + "mushroom_cradle": "Berceau", "mushroom_plug": "Bouchon", "mushroom_dot6": "Pois Ø6", + "mushroom_dot8": "Pois Ø8", "mushroom_dot10": "Pois Ø10"}, + }, + "box.3mf": { # boîtier mural : n'imprimer qu'un des deux fonds + "scad": "tinysensor_box.scad", + "parts": {"Fond extérieur": ['part="base"', 'usage="ext"'], "Fond intérieur": ['part="base"', 'usage="int"'], + "Couvercle": ['part="lid"']}, + "plates": [("Fond extérieur", ["Fond extérieur"]), ("Fond intérieur", ["Fond intérieur"]), + ("Couvercle", ["Couvercle"])], + }, + "shield.3mf": { # abri de sonde + "scad": "sensor_shield.scad", + "parts": {"Chapeau": ['part="cap"'], "Assiette": ['part="plate"'], "Fond": ['part="base"'], + "Clip": ['part="clip"']}, + "plates": [("Abri", ["Chapeau", "Fond", "Clip"]), ("Assiettes", ["Assiette"] * 4)], + }, + "stand.3mf": { # pied de table + "scad": "stand.scad", + "parts": {"Mât": ['part="mast"'], "Pied": ['part="foot"']}, + "plates": [("Mât", ["Mât"]), ("Pied", ["Pied"])], + }, +} + +NS = ('xmlns="http://schemas.microsoft.com/3dmanufacturing/core/2015/02" ' + 'xmlns:BambuStudio="http://schemas.bambulab.com/package/2021" ' + 'xmlns:p="http://schemas.microsoft.com/3dmanufacturing/production/2015/06" requiredextensions="p"') +REL_3D = "http://schemas.microsoft.com/3dmanufacturing/2013/01/3dmodel" + + +def export(scad, args, out): + cmd = [str(OPENSCAD), "--backend=manifold", "-o", str(out)] + for a in args: + cmd += ["-D", a] + r = subprocess.run(cmd + [str(HERE / scad)], capture_output=True, text=True) + if r.returncode or re.search(r"WARNING|ERROR", r.stderr): + sys.exit(f"OpenSCAD {scad} {args} :\n{r.stderr}") + model = next(n for n in zipfile.ZipFile(out).namelist() if n.endswith(".model")) + t = zipfile.ZipFile(out).read(model).decode() + v = [tuple(map(float, m)) for m in re.findall(r'\n' for x, y, z in v) + \ + " \n \n" + "".join( + f' \n' for a, b, d in f) + " \n " + return {"mesh": mesh, "center": c, "lo_z": lo[2] - c[2], "size": [b - a for a, b in zip(lo, hi)]} + + +def meta(el, key, value=None): + m = el.find(f"metadata[@key='{key}']") + if value is None: + return None if m is None else m.get("value") + if m is None: # avec les autres métadonnées, avant les , + m = ET.Element("metadata", key=key) + el.insert(len(el.findall("metadata")), m) + m.set("value", value) + + +def skeleton(src, plate_names): + """Projet vide : réglages d'imprimante et de filament du TEMPLATE, une plaque par nom.""" + tpl = zipfile.ZipFile(TEMPLATE) + for n in ("[Content_Types].xml", "Metadata/project_settings.config", "Metadata/slice_info.config"): + (src / n).parent.mkdir(parents=True, exist_ok=True) + (src / n).write_bytes(tpl.read(n)) + head = re.match(r".*?", tpl.read("3D/3dmodel.model").decode(), re.S).group(0) + (src / "3D" / "_rels").mkdir(parents=True) + (src / "3D" / "Objects").mkdir() + (src / "3D" / "3dmodel.model").write_text( + re.sub(r'()[^<]*', r"\1", head) + f'\n \n \n \n\n') + rels = ('\n\n{}\n') + (src / "_rels").mkdir() + (src / "_rels" / ".rels").write_text(rels.format(f' \n')) + (src / "3D" / "_rels" / "3dmodel.model.rels").write_text(rels.format("")) + tpl_plate = ET.fromstring(tpl.read("Metadata/model_settings.config")).find("plate") + root = ET.Element("config") + for i, name in enumerate(plate_names, 1): + p = ET.SubElement(root, "plate") + meta(p, "plater_id", str(i)) + meta(p, "plater_name", name) + for k in ("locked", "filament_map_mode", "filament_maps"): + meta(p, k, meta(tpl_plate, k)) + ET.ElementTree(root).write(src / "Metadata" / "model_settings.config", encoding="UTF-8", xml_declaration=True) + + +def plate_origin(k, n): + cols = math.ceil(math.sqrt(n)) + return (k % cols) * BED * 1.2, -(k // cols) * BED * 1.2 + + +def layout(sizes): + """Pièces en rangées, bloc centré sur la plaque ; centres [(x, y)] relatifs au coin de la plaque.""" + rows, row, w = [], [], 0 + for s in sizes: + if row and w + GAP + s[0] > BED - 2 * GAP: + rows.append(row) + row, w = [], 0 + row.append(s) + w += (GAP if len(row) > 1 else 0) + s[0] + rows.append(row) + heights = [max(s[1] for s in r) for r in rows] + total = sum(heights) + GAP * (len(rows) - 1) + width = max(sum(s[0] for s in r) + GAP * (len(r) - 1) for r in rows) + if total > BED or width > BED: + sys.exit(f"Pièces trop grandes pour une plaque de {BED} mm : {sizes}") + out, y = [], (BED + total) / 2 + for r, h in zip(rows, heights): + x = (BED - (sum(s[0] for s in r) + GAP * (len(r) - 1))) / 2 + for s in r: + out.append((x + s[0] / 2, y - h / 2)) + x += s[0] + GAP + y -= h + GAP + return out + + +def update(path, spec): + work = Path(tempfile.mkdtemp()) + src = work / "src" + created = not path.exists() + if created: + skeleton(src, [p for p, _ in spec["plates"]]) + else: + zipfile.ZipFile(path).extractall(src) + cfg_path = src / "Metadata" / "model_settings.config" + model_path = src / "3D" / "3dmodel.model" + cfg = ET.parse(cfg_path) + root = cfg.getroot() + model = model_path.read_text() + parts = spec["parts"] + + # maillages : un export par pièce, en parallèle + with ThreadPoolExecutor() as ex: + new = dict(zip(parts, ex.map(lambda ia: export(spec["scad"], ia[1], work / f"part{ia[0]}.3mf"), + enumerate(parts.values())))) + + # identification et renommage + objs = {} + for o in root.findall("object"): + part = o.find("part") + name = meta(o, "name") + if name not in parts: + stem = Path(meta(part, "source_file") or "").stem + name = next((v for k, v in spec.get("from_file", {}).items() if re.fullmatch(k + r"(_x\d+)?", stem)), None) + if name is None: + sys.exit(f"{path.name} : objet {o.get('id')} non reconnu ({meta(o, 'name')})") + meta(o, "name", name) + meta(part, "name", name) + objs[o.get("id")] = (name, o, part) + + def add_object(name, plate_name, xy): + nonlocal model + ids = [int(i) for i in re.findall(r'\n\n' + ' 1\n \n' + f' \n \n \n' + ' \n \n\n') + model = model.replace(" ", + f' \n \n' + f' \n \n \n ', 1) + model = model.replace(" ", f' \n ', 1) + rels = src / "3D" / "_rels" / "3dmodel.model.rels" + rels.write_text(rels.read_text().replace("", + f' \n')) + o = ET.Element("object", id=str(obj_id)) + meta(o, "name", name) + meta(o, "extruder", "1") + part = ET.SubElement(o, "part", id=str(mesh_id), subtype="normal_part") + meta(part, "name", name) + meta(part, "matrix", "1 0 0 0 0 1 0 0 0 0 1 0 0 0 0 1") + plates = root.findall("plate") + root.insert(list(root).index(plates[0]), o) + plate = next(p for p in plates if meta(p, "plater_name") == plate_name) + inst = ET.SubElement(plate, "model_instance") + meta(inst, "object_id", str(obj_id)) + meta(inst, "instance_id", "0") + meta(inst, "identify_id", str(1 + max([int(meta(i, "identify_id")) for i in root.iter("model_instance") + if meta(i, "identify_id")] or [100]))) + objs[str(obj_id)] = (name, o, part) + + if created: + n = len(spec["plates"]) + for k, (plate_name, names) in enumerate(spec["plates"]): + ox, oy = plate_origin(k, n) + for name, (x, y) in zip(names, layout([new[nm]["size"][:2] for nm in names])): + add_object(name, plate_name, (ox + x, oy + y)) + for name, (plate_name, xy) in spec.get("add", {}).items(): + if all(nm != name for nm, _, _ in objs.values()): + add_object(name, plate_name, xy) + missing = set(parts) - {nm for nm, _, _ in objs.values()} + if missing: + print(f"{path.name} : absent du projet, à ajouter dans Orca : {', '.join(sorted(missing))}") + + for name, o, _ in objs.values(): + for k, v in spec.get("settings", {}).get(name, {}).items(): + if meta(o, k) is None: + meta(o, k, v) + + done = set() + for oid, (name, o, part) in objs.items(): + comp = re.search(rf']*>\s*\s*]*>\s*)(.*?|)', t, re.S) + lo_old = min((float(z) for z in re.findall(r'z="([^"]+)"', old.group(2))), default=None) + if (f, mid) not in done: # des exemplaires peuvent partager un maillage (pois du champignon) + f.write_text(t[:old.start(2)] + new[name]["mesh"] + t[old.end(2):]) + done.add((f, mid)) + # le bas de la pièce reste sur le plateau ; rotations autour de Z seulement + item = re.search(rf'(]*transform=")([^"]+)(")', model) + m = [float(a) for a in item.group(2).split()] + if max(abs(m[2]), abs(m[5]), abs(m[6]), abs(m[7])) > 1e-6 or abs(m[8] - 1) > 1e-6: + sys.exit(f"{path.name} : {name} basculé dans Orca, mise à jour refusée") + m[11] = -new[name]["lo_z"] if lo_old is None else m[11] + lo_old - new[name]["lo_z"] # objet neuf : posé + model = model[:item.start(2)] + " ".join(f"{a:.9g}" for a in m) + model[item.end(2):] + for k, v in zip("xyz", new[name]["center"]): + meta(part, "source_offset_" + k, repr(v)) + + model_path.write_text(model) + cfg.write(cfg_path, encoding="UTF-8", xml_declaration=True) + out = work / "out.3mf" + with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as z: + for p in sorted(src.rglob("*"), key=lambda p: p.name != "[Content_Types].xml"): # en tête de l'archive + if p.is_file(): + z.write(p, p.relative_to(src).as_posix()) + if not created: + shutil.copy(path, path.with_suffix(".3mf.bak")) + shutil.move(out, path) + shutil.rmtree(work) + counts = {} + for nm, _, _ in objs.values(): + counts[nm] = counts.get(nm, 0) + 1 + print(f"{path.name} : {'créé' if created else 'mis à jour'}, {len(objs)} objets {counts}" + + ("" if created else f" ; ancien fichier : {path.name}.bak")) + + +targets = [Path(a) for a in sys.argv[1:]] or [PRINT / n for n in PROJECTS] +for p in targets: + update(p, PROJECTS[p.name])