cosplay: Touhou/Yasaka Kanako #11

Open
aniva wants to merge 59 commits from touhou/yasaka-kanako into main
10 changed files with 3298 additions and 6 deletions

View File

@ -3,6 +3,9 @@
This is the design repository for NorCal Hakkero Factory No. 1, where we use
parametric CAD to make cosplay props.
> NorCal Hakkero Factory № 1
> 北加国営八卦炉第一工場
## Development
Most cosplay schematics are created with Blender, CadQuery, and Inkscape. To

View File

@ -25,13 +25,16 @@ class Role(Flag):
PARENT = auto()
CHILD = auto()
CASING = auto()
STATOR = auto()
ROTOR = auto()
BEARING = auto()
# Springs, cushions
DAMPING = auto()
# Main structural support
STRUCTURE = auto()
DECORATION = auto()
ELECTRONIC = auto()
MOTION = auto()
MOTOR = auto()
# Fasteners, etc.
CONNECTION = auto()
@ -59,11 +62,14 @@ ROLE_COLOR_MAP = {
Role.PARENT: _color('blue4', 0.6),
Role.CASING: _color('dodgerblue3', 0.6),
Role.CHILD: _color('darkorange2', 0.6),
Role.STATOR: _color('gray', 0.5),
Role.ROTOR: _color('blue3', 0.5),
Role.BEARING: _color('green3', 0.8),
Role.DAMPING: _color('springgreen', 1.0),
Role.STRUCTURE: _color('gray', 0.4),
Role.DECORATION: _color('lightseagreen', 0.4),
Role.ELECTRONIC: _color('mediumorchid', 0.7),
Role.MOTION: _color('thistle3', 0.7),
Role.MOTOR: _color('thistle3', 0.7),
Role.CONNECTION: _color('steelblue3', 0.8),
Role.HANDLE: _color('tomato4', 0.8),
}
@ -84,6 +90,8 @@ class Material(Enum):
ACRYLIC_TRANSLUSCENT = 1.18, _color('ivory2', 0.8)
ACRYLIC_TRANSPARENT = 1.18, _color('ghostwhite', 0.5)
STEEL_SPRING = 7.8, _color('gray', 0.8)
STEEL_STAINLESS = 7.8, _color('gray', 0.9)
METAL_AL = 2.7, _color('gray', 0.6)
METAL_BRASS = 8.5, _color('gold1', 0.8)
def __init__(self, density: float, color: Cq.Color):

View File

@ -83,11 +83,16 @@ class BatteryBox18650(Item):
battery_dist: float = 20.18
height: float = 19.66
# space from bottom to battery begin
thickness: float = 1.66
thickness: float = 2.28
battery_diam: float = 18.48
battery_height: float = 68.80
n_batteries: int = 3
battery_gap: float = 2.0
diam_thread: float = 3.0
hole_dy: float = 39.50 / 2
def __post_init__(self):
assert 2 * self.thickness < min(self.length, self.height)
@ -95,13 +100,20 @@ class BatteryBox18650(Item):
def name(self) -> str:
return f"BatteryBox 18650*{self.n_batteries}"
@property
def holes(self) -> list[Cq.Location]:
return [
Cq.Location.from2d(0, self.hole_dy),
Cq.Location.from2d(0, -self.hole_dy),
]
@property
def role(self) -> Role:
return Role.ELECTRONIC
def generate(self) -> Cq.Workplane:
width = self.width_base + self.battery_dist * (self.n_batteries - 1) + self.battery_diam
return (
result = (
Cq.Workplane('XY')
.box(
length=self.length,
@ -117,7 +129,7 @@ class BatteryBox18650(Item):
centered=(True, True, False),
combine='cut',
)
.copyWorkplane(Cq.Workplane('XY', origin=(-self.battery_height/2, 0, self.thickness + self.battery_diam/2)))
.copyWorkplane(Cq.Workplane('XY', origin=(-self.battery_height/2, 0, self.thickness + self.battery_diam/2 + self.battery_gap)))
.rarray(
xSpacing=1,
ySpacing=self.battery_dist,
@ -132,4 +144,16 @@ class BatteryBox18650(Item):
centered=(True, True, False),
combine=True,
)
.copyWorkplane(Cq.Workplane('XY'))
)
hole = Cq.Solid.makeCylinder(
radius=self.diam_thread/2,
height=self.thickness,
)
result -= hole.moved(0, self.hole_dy)
result -= hole.moved(0, -self.hole_dy)
result.tagAbsolute("holeT0", (0, self.hole_dy, self.thickness), direction="+Z")
result.tagAbsolute("holeT1", (0, -self.hole_dy, self.thickness), direction="+Z")
result.tagAbsolute("holeB0", (0, self.hole_dy, 0), direction="-Z")
result.tagAbsolute("holeB1", (0, -self.hole_dy, 0), direction="-Z")
return result

View File

@ -11,6 +11,7 @@ class FlatHeadBolt(Item):
height_head: float
diam_thread: float
height_thread: float
pitch: float = 1.0
@property
def name(self) -> str:
@ -34,7 +35,7 @@ class FlatHeadBolt(Item):
centered=(True, True, False))
)
rod.faces("<Z").tag("tip")
rod.faces(">Z").tag("root")
rod.tagAbsolute("root", (0, 0, self.height_thread), direction="-Z")
rod = rod.union(head.located(Cq.Location((0, 0, self.height_thread))))
return rod

View File

@ -0,0 +1,37 @@
import nhf.touhou.yasaka_kanako.mirror as MM
import nhf.touhou.yasaka_kanako.onbashira as MO
import nhf.touhou.yasaka_kanako.shimenawa as MS
from nhf.build import Model, TargetKind, target, assembly, submodel
import nhf.utils
from dataclasses import dataclass, field
import cadquery as Cq
@dataclass
class Parameters(Model):
mirror: MM.Mirror = field(default_factory=lambda: MM.Mirror())
onbashira: MO.Onbashira = field(default_factory=lambda: MO.Onbashira())
shimenawa: MS.Shimenawa = field(default_factory=lambda: MS.Shimenawa())
def __post_init__(self):
super().__init__(name="yasaka-kanako")
@submodel(name="mirror")
def submodel_mirror(self) -> Model:
return self.mirror
@submodel(name="onbashira")
def submodel_onbashira(self) -> Model:
return self.onbashira
@submodel(name="shimenawa")
def submodel_shimenawa(self) -> Model:
return self.shimenawa
if __name__ == '__main__':
import sys
p = Parameters()
if len(sys.argv) == 1:
p.build_all()
sys.exit(0)

View File

@ -0,0 +1,199 @@
#define USE_MOTOR 1
#define USE_LED 1
#define USE_DISPLAY 1
#define pinButtonMode 9
#define pinDiag 6
// Main LED strip setup
#define pinLED 3
#define NUM_LEDS 20
#define LED_PART 10
#define BRIGHTNESS 250
#define LED_TYPE WS2811
// Relay controlled motor
#define pinMotor 7
#if USE_LED
#include <FastLED.h>
int cycles = 100;
int cycle_duration = 100;
CRGB leds[NUM_LEDS];
CRGB color_red;
CRGB color_blue;
CRGB color_green;
#endif
#if USE_DISPLAY
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
#define SCREEN_WIDTH 128
#define SCREEN_HEIGHT 32
#define OLED_RESET -1
Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, OLED_RESET);
#endif
// Program state
bool stateButtonMode = false;
int programId = 0;
bool programChanged = true;
#define MAX_PROGRAMS 3
bool flag_motor = false;
bool flag_lighting = false;
void setup() {
pinMode(LED_BUILTIN, OUTPUT);
pinMode(pinButtonMode, INPUT);
#if USE_LED
// Calculate colours
hsv2rgb_spectrum(CHSV(4, 255, 100), color_red);
hsv2rgb_spectrum(CHSV(170, 255, 100), color_blue);
hsv2rgb_spectrum(CHSV(90, 255, 100), color_green);
pinMode(pinLED, OUTPUT);
#endif
pinMode(pinDiag, OUTPUT);
#if USE_MOTOR
pinMode(pinMotor, OUTPUT);
#endif
#if USE_LED
// Main LED strip
FastLED.addLeds<LED_TYPE, pinLED, RGB>(leds, NUM_LEDS);
#endif
#if USE_DISPLAY
Serial.begin(9600);
// SSD1306_SWITCHCAPVCC = generate display voltage from 3.3V internally
if(!display.begin(SSD1306_SWITCHCAPVCC, 0x3C)) { // Address 0x3D for 128x64
Serial.println(F("SSD1306 allocation failed"));
for(;;); // Don't proceed, loop forever
pinMode(pinLED, OUTPUT);
digitalWrite(pinLED, HIGH);
}
#endif
}
void loop() {
int buttonState = digitalRead(pinButtonMode);
if (buttonState && !stateButtonMode) {
programId = (programId + 1) % MAX_PROGRAMS;
programChanged = true;
stateButtonMode = true;
}
if (!buttonState) {
stateButtonMode = false;
}
switch (programId)
{
case 0:
program_off();
break;
case 1:
program_still();
break;
case 2:
program_rotate();
break;
default:
break;
}
if (programChanged) {
update_screen();
programChanged = false;
}
}
// Utility for updating LEDs
void fill_segmented(CRGB c1, CRGB c2)
{
#if USE_LED
//fill_solid(leds, LED_PART, c1);
fill_gradient_RGB(leds, LED_PART, CRGB::Black ,c1);
fill_gradient_RGB(leds + LED_PART, NUM_LEDS - LED_PART, CRGB::Black, c2);
FastLED.show();
#endif
}
void set_motor(bool flag)
{
#if USE_MOTOR
if (flag) {
digitalWrite(pinMotor, HIGH);
flag_motor = true;
}
else {
digitalWrite(pinMotor, LOW);
flag_motor = false;
}
#endif
}
// Update current display status
void update_screen()
{
#if USE_DISPLAY
display.clearDisplay();
display.setTextSize(2);
display.setTextColor(SSD1306_WHITE);
display.setCursor(0,0);
display.println("Yasaka K.");
display.print("P");
display.print(programId);
display.print(" ");
if (flag_motor) {
display.print("M");
}
if (flag_lighting) {
display.print("L");
}
display.display();
#endif
}
void program_off()
{
if (programChanged)
{
set_motor(false);
#if USE_LED
flag_lighting = false;
fill_solid(leds, NUM_LEDS, CRGB::Black);
FastLED.show();
#endif
}
}
void program_still()
{
if (programChanged)
{
set_motor(false);
#if USE_LED
flag_lighting = true;
fill_segmented(CRGB::Green, CRGB::Orange);
FastLED.show();
#endif
}
}
void program_rotate()
{
if (programChanged)
{
set_motor(true);
}
#if USE_LED
flag_lighting = true;
fill_segmented(CRGB::Green, CRGB::Orange);
delay(cycle_duration/2);
fill_solid(leds, NUM_LEDS, CRGB::Black);
FastLED.show();
delay(cycle_duration/2);
#endif
}

View File

@ -0,0 +1,186 @@
from dataclasses import dataclass, field
import cadquery as Cq
from nhf.build import Model, TargetKind, target, assembly, submodel
from nhf.materials import Role, Material
import nhf.touhou.yasaka_kanako.onbashira as MO
import nhf.utils
@dataclass
class Mirror(Model):
"""
Kanako's mirror, made of three levels.
The mirror suface is sandwiched between two layers of wood. As such, its
dimensions have to sit in between that of the aperature on the surface, and
the outer walls. The width/height here refers to the outer edge's width and height
"""
width: float = 100.0
height: float = 120.0
inner_gap: float = 3.0
outer_gap: float = 3.0
core_thickness: float = 25.4 / 8
casing_thickness: float = 25.4 / 8
flange_r0: float = 8.0
flange_r1: float = 20.0
flange_y1: float = 12.0
flange_y2: float = 25.0
flange_hole_r: float = 8.0
wing_x1: float = 15.0
wing_x2: float = 24.0
wing_r1: float = 10.0
wing_r2: float = 16.0
tail_r0: float = 8.0
tail_r1: float = 13.0
tail_y1: float = 16.0
tail_y2: float = 29.0
# Necklace hole
hole_diam: float = 5.0
material_mirror: Material = Material.ACRYLIC_TRANSPARENT
material_casing: Material = Material.WOOD_BIRCH
@target(name="core", kind=TargetKind.DXF)
def profile_core(self) -> Cq.Sketch:
rx = self.width/2 - self.outer_gap
ry = self.height/2 - self.outer_gap
return Cq.Sketch().ellipse(rx, ry)
def core(self) -> Cq.Workplane:
return (
Cq.Workplane()
.placeSketch(self.profile_core())
.extrude(self.core_thickness)
)
@target(name="casing-bot", kind=TargetKind.DXF)
def profile_casing_bot(self) -> Cq.Sketch:
"""
Base of the casing with no holes carved out
"""
yt = self.height / 2 - self.outer_gap
yh = (self.flange_y1 + self.flange_y2) / 2
flange = (
Cq.Sketch()
.polygon([
(self.flange_r0, yt),
(self.flange_r0, yt + self.flange_y1),
(self.flange_r1, yt + self.flange_y1),
(self.flange_r1, yt + self.flange_y2),
(-self.flange_r1, yt + self.flange_y2),
(-self.flange_r1, yt + self.flange_y1),
(-self.flange_r0, yt + self.flange_y1),
(-self.flange_r0, yt),
])
.push([
(self.flange_hole_r, yt+yh),
(-self.flange_hole_r, yt+yh),
])
.circle(self.hole_diam/2, mode="s")
)
tail = (
Cq.Sketch()
.polygon([
(+self.tail_r0, -yt),
(+self.tail_r0, -yt - self.tail_y1),
(+self.tail_r1, -yt - self.tail_y1),
(+self.tail_r1, -yt - self.tail_y2),
(-self.tail_r1, -yt - self.tail_y2),
(-self.tail_r1, -yt - self.tail_y1),
(-self.tail_r0, -yt - self.tail_y1),
(-self.tail_r0, -yt),
])
)
return (
Cq.Sketch()
.ellipse(self.width/2, self.height/2)
.boolean(flange, mode="a")
.boolean(tail, mode="a")
.boolean(self.profile_wing(-1), mode="a")
.boolean(self.profile_wing(1), mode="a")
)
def casing_bot(self) -> Cq.Workplane:
return (
Cq.Workplane()
.placeSketch(self.profile_casing_bot())
.extrude(self.casing_thickness)
)
def profile_wing(self, sign: float=1) -> Cq.Sketch:
xt = self.width / 2 - self.outer_gap
return (
Cq.Sketch()
.polygon([
(sign*xt, self.wing_r1),
(sign*(xt+self.wing_x1), self.wing_r1),
(sign*(xt+self.wing_x1), self.wing_r2),
(sign*(xt+self.wing_x2), self.wing_r2),
(sign*(xt+self.wing_x2), -self.wing_r2),
(sign*(xt+self.wing_x1), -self.wing_r2),
(sign*(xt+self.wing_x1), -self.wing_r1),
(sign*xt, -self.wing_r1),
])
)
@target(name="casing-mid", kind=TargetKind.DXF)
def profile_casing_mid(self) -> Cq.Sketch:
rx = self.width/2 - self.outer_gap
ry = self.height/2 - self.outer_gap
return (
self.profile_casing_bot()
.ellipse(rx, ry, mode="s")
)
def casing_mid(self) -> Cq.Workplane:
return (
Cq.Workplane()
.placeSketch(self.profile_casing_mid())
.extrude(self.core_thickness)
)
@target(name="casing-top", kind=TargetKind.DXF)
def profile_casing_top(self) -> Cq.Sketch:
rx = self.width/2 - self.outer_gap - self.inner_gap
ry = self.height/2 - self.outer_gap - self.inner_gap
return (
self.profile_casing_bot()
.ellipse(rx, ry, mode="s")
)
def casing_top(self) -> Cq.Workplane:
return (
Cq.Workplane()
.placeSketch(self.profile_casing_top())
.extrude(self.casing_thickness)
)
@assembly()
def assembly(self) -> Cq.Assembly:
return (
Cq.Assembly()
.addS(
self.core(),
name="core",
material=self.material_mirror,
role=Role.DECORATION,
loc=Cq.Location(0, 0, self.casing_thickness)
)
.addS(
self.casing_bot(),
name="casing_bot",
material=self.material_casing,
role=Role.CASING,
)
.addS(
self.casing_mid(),
name="casing_mid",
material=self.material_casing,
role=Role.CASING,
loc=Cq.Location(0, 0, self.casing_thickness)
)
.addS(
self.casing_top(),
name="casing_top",
material=self.material_casing,
role=Role.CASING,
loc=Cq.Location(0, 0, self.core_thickness + self.casing_thickness)
)
)

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,264 @@
from nhf.build import Model, TargetKind, target, assembly, submodel
from nhf.materials import Role, Material
import nhf.utils
from nhf.parts.fasteners import FlatHeadBolt, HexNut, Washer
from nhf.parts.electronics import ArduinoUnoR3, BatteryBox18650
from typing import Optional, Union
import math
from dataclasses import dataclass, field
import cadquery as Cq
NUT_COMMON = HexNut(
# FIXME: weigh
mass=0.0,
diam_thread=6.0,
pitch=1.0,
thickness=5.0,
width=9.89,
)
BOLT_COMMON = FlatHeadBolt(
# FIXME: weigh
mass=0.0,
diam_head=12.8,
height_head=2.8,
diam_thread=6.0,
height_thread=30.0,
pitch=1.0,
)
@dataclass
class Shimenawa(Model):
"""
The ring
"""
diam_inner: float = 43.0
diam_outer: float = 43.0 + 9 * 2
diam_hole_outer: float = 8.0
hole_ext: float = 2.0
hole_z: float = 15.0
pipe_fitting_angle_span: float = 6.0
pipe_joint_length: float = 120.0
pipe_joint_outer_thickness: float = 5.0
pipe_joint_inner_thickness: float = 4.0
pipe_joint_inner_angle_span: float = 120.0
pipe_joint_taper: float = 5.0
pipe_joint_taper_length: float = 10.0
ear_dr: float = 6.0
ear_hole_diam: float = 10.0
ear_radius: float = 15.0
ear_thickness: float = 10.0
main_circumference: float = 3600.0
material_fitting: Material = Material.PLASTIC_PLA
def __post_init__(self):
assert self.diam_inner < self.diam_outer
@property
def main_radius(self) -> float:
return self.main_circumference / (2 * math.pi)
@target(name="pipe-fitting-curved")
def pipe_fitting_curved(self) -> Cq.Workplane:
r_minor = self.diam_outer/2 + self.pipe_joint_outer_thickness
a1 = self.pipe_fitting_angle_span
outer = Cq.Solid.makeTorus(
radius1=self.main_radius,
radius2=r_minor,
)
inner = Cq.Solid.makeTorus(
radius1=self.main_radius,
radius2=self.diam_outer/2,
)
angle_intersector = Cq.Solid.makeCylinder(
radius=self.main_radius + r_minor,
height=r_minor*2,
angleDegrees=a1,
pnt=(0,0,-r_minor)
).rotate((0,0,0),(0,0,1),-a1/2)
result = (outer - inner) * angle_intersector
ear_outer = Cq.Solid.makeCylinder(
radius=self.ear_radius,
height=self.ear_thickness,
pnt=(0,-self.ear_thickness/2,0),
dir=(0,1,0),
)
ear_hole = Cq.Solid.makeCylinder(
radius=self.ear_hole_diam/2,
height=self.ear_thickness,
pnt=(-self.ear_dr,-self.ear_thickness/2,0),
dir=(0,1,0),
)
ear = (ear_outer - ear_hole).moved(self.main_radius - r_minor, 0, 0)
result += ear - inner
return result
@target(name="pipe-joint-outer")
def pipe_joint_outer(self) -> Cq.Workplane:
"""
Used to joint two pipes together (outside)
"""
r1 = self.diam_outer / 2 + self.pipe_joint_outer_thickness
h = self.pipe_joint_length
result = (
Cq.Workplane()
.cylinder(
radius=r1,
height=self.pipe_joint_length,
)
)
cut_interior = Cq.Solid.makeCylinder(
radius=self.diam_outer/2,
height=h,
pnt=(0, 0, -h/2)
)
rh = r1 + self.hole_ext
add_hole = Cq.Solid.makeCylinder(
radius=self.diam_hole_outer/2,
height=rh*2,
pnt=(-rh, 0, 0),
dir=(1, 0, 0),
)
cut_hole = Cq.Solid.makeCylinder(
radius=BOLT_COMMON.diam_thread/2,
height=rh*2,
pnt=(-rh, 0, 0),
dir=(1, 0, 0),
)
z = self.hole_z
result = (
result
+ add_hole.moved(0, 0, -z)
+ add_hole.moved(0, 0, z)
- cut_hole.moved(0, 0, -z)
- cut_hole.moved(0, 0, z)
- cut_interior
)
ear_outer = Cq.Solid.makeCylinder(
radius=self.ear_radius,
height=self.ear_thickness,
pnt=(0, r1, -self.ear_thickness/2),
)
ear_hole = Cq.Solid.makeCylinder(
radius=self.ear_hole_diam/2,
height=self.ear_thickness,
pnt=(0,r1+self.ear_dr,-self.ear_thickness/2),
)
ear = ear_outer - ear_hole - cut_interior
return result + ear
@target(name="pipe-joint-inner")
def pipe_joint_inner(self) -> Cq.Workplane:
"""
Used to joint two pipes together (inside)
"""
r1 = self.diam_inner / 2
r2 = r1 - self.pipe_joint_taper
r3 = r2 - self.pipe_joint_inner_thickness
h = self.pipe_joint_length
h0 = h - self.pipe_joint_taper_length*2
core = Cq.Solid.makeCylinder(
radius=r2,
height=h0/2,
)
centre_cut = Cq.Solid.makeCylinder(
radius=r3,
height=h0/2,
)
taper = Cq.Solid.makeCone(
radius1=r2,
radius2=r1,
height=(h - h0) / 2,
pnt=(0, 0, h0/2),
)
centre_cut_taper = Cq.Solid.makeCone(
radius1=r3,
radius2=r3 + self.pipe_joint_taper,
height=(h - h0) / 2,
pnt=(0, 0, h0/2),
)
angle_intersector = Cq.Solid.makeCylinder(
radius=r1,
height=h,
angleDegrees=self.pipe_joint_inner_angle_span
).rotate((0,0,0), (0,0,1), -self.pipe_joint_inner_angle_span/2)
result = (taper + core - centre_cut - centre_cut_taper) * angle_intersector
result += result.mirror("XY")
add_hole = Cq.Solid.makeCylinder(
radius=self.diam_hole_outer/2,
height=self.hole_ext,
pnt=(r3, 0, 0),
dir=(-1, 0, 0),
)
cut_hole = Cq.Solid.makeCylinder(
radius=BOLT_COMMON.diam_thread/2,
height=r1,
pnt=(0, 0, 0),
dir=(r1, 0, 0),
)
z = self.hole_z
# avoid collisions
nut_x = r3 - self.hole_ext - NUT_COMMON.thickness
nut = NUT_COMMON.generate().val().rotate((0,0,0),(0,1,0),90)
result = (
result
+ add_hole.moved(0, 0, z)
+ add_hole.moved(0, 0, -z)
- cut_hole.moved(0, 0, z)
- cut_hole.moved(0, 0, -z)
- nut.moved(nut_x, 0, z)
- nut.moved(nut_x, 0, -z)
)
return result
@assembly()
def assembly_pipe_joint(self) -> Cq.Assembly:
a = (
Cq.Assembly()
.addS(
self.pipe_joint_outer(),
name="joint_outer",
material=self.material_fitting,
role=Role.STRUCTURE,
)
.addS(
self.pipe_joint_inner(),
name="joint_inner1",
material=self.material_fitting,
role=Role.STRUCTURE,
)
.addS(
self.pipe_joint_inner(),
name="joint_inner2",
material=self.material_fitting,
role=Role.STRUCTURE,
loc=Cq.Location.rot2d(180),
)
)
return a
@assembly()
def assembly(self) -> Cq.Assembly:
a = (
Cq.Assembly()
.addS(
self.pipe_fitting_curved(),
name="fitting1",
material=self.material_fitting,
role=Role.STRUCTURE,
)
.add(
self.assembly_pipe_joint(),
name="pipe_joint",
)
)
return a

View File

@ -162,6 +162,15 @@ def tagPlane(self, tag: str,
Cq.Workplane.tagPlane = tagPlane
def tag_absolute(
self,
tag: str,
loc: Union[Cq.Location, Tuple[float, float, float]],
direction: Union[str, Cq.Vector, Tuple[float, float, float]] = '+Z'):
return self.pushPoints([loc]).tagPlane(tag, direction=direction)
Cq.Workplane.tagAbsolute = tag_absolute
def make_sphere(r: float = 2) -> Cq.Solid:
"""
Makes a full sphere. The default function makes a hemisphere