GLaDOS Vol 7 · The Body
ch 63 / 99
Chapter 63

Printing the Shell

Nothing left to type

The build record at the end of volume 6 closed with three questions no file could answer. What does she draw from the supply while the servos move and the model thinks? Do the microphones hear you from the kitchen? What is her eye doing during the four seconds a reply takes? Each one is a question about an object, and the object does not exist yet. This volume makes it, starting on a printer bed with the part everything else bolts into.

Her shell has to carry a Raspberry Pi, two servos behind an eye, a ring of LEDs, a speaker and a microphone board, and survive being picked up, knocked off a desk edge and left running in a warm room for six hours. All of that reaches the printer as five or six numbers: layer height, wall count, infill, nozzle and bed temperature, supports on or off. Get one wrong and nothing warns you. A printer executes a bad plan as cheerfully as a good one, and the news arrives nine hours later as a base plate curling off the bed or a bracket ear that snaps the first time you tighten a screw into it.

The workflow everyone starts with is memory. You dial the shell in, print it, then open the slicer the following week for a servo bracket in PETG with the profile still loaded from Tuesday, cooking a material that wants 240 degrees at 205. No error appears anywhere. The layers never bond, and you learn about it with a screwdriver in your hand.

So the rule for every part in this volume: the settings that produced a part live in a committed file, that file is checked against a per-material spec before anything is sliced, and the check is a gate that exits non-zero rather than a report you are trusted to read. Chapter 34 built exactly this machinery for her audio settings, where a bad value cost thirty seconds. Here a bad value costs an evening and a third of a spool.

◆ Note — what is on the bench, and how to read the numbers below

A bed-slinger FDM printer with a 0.4 mm nozzle, one spool of PLA, one of PETG, a digital caliper, and a slicer that can be driven from a command line. PrusaSlicer and OrcaSlicer both accept the flags used here; if yours is GUI-only, the profile file is still the record and the numbers still get typed once, from the file, checked. Every measurement printed in this chapter came off one printer with one caliper. Yours will land somewhere else, and half the chapter is about finding out where.

Where the numbers come from

Four parts make up her head: an outer shell, an eye housing, a base plate, and the bracket that holds a servo. The settings differ per part because the physics does.

Material follows heat. PLA prints easily, looks good, and starts going soft around 60 degrees, which a servo body under continuous load reaches on its own, never mind a summer afternoon and a Pi in a closed enclosure. Anything touching a motor or the board gets PETG, stiff another 20 degrees up and inclined to bend before it snaps. Stiffness follows walls, not infill: a printed part is a thin shell around a lattice, so a fourth perimeter does more for a bracket than raising infill from 20 to 40 percent, in a fraction of the print time. Strength follows orientation, because an FDM part is a stack of welded layers and the weld is the weak direction. A bracket ear printed standing up shears along a layer line under screw torque; the same ear lying flat pulls against solid plastic.

Those decisions become a file per part, and the file is what gets committed next to the model.

▣ Build · stage 1 — the profile, on disk and readable
// configs/print_profiles/outer_shell.json
{
  "part": "outer_shell",
  "material": "PLA",
  "layer_height_mm": 0.2,
  "wall_count": 4,
  "infill_percent": 20,
  "nozzle_temp_c": 205,
  "bed_temp_c": 60,
  "fan_percent": 100,
  "hole_clearance_mm": 0.4,
  "overhangs": true,
  "supports_enabled": true,
  "near_heat": false
}
# labs/print_check.py
import json
from pathlib import Path

PROFILES = Path("configs/print_profiles")

def load_profile(path: Path) -> dict:
    return json.loads(path.read_text())

if __name__ == "__main__":
    profile = load_profile(PROFILES / "outer_shell.json")
    for key in ("part", "material", "layer_height_mm", "wall_count", "nozzle_temp_c"):
        print(f"{key:<18}{profile[key]}")
$ uv run python -m labs.print_check
part              outer_shell
material          PLA
layer_height_mm   0.2
wall_count        4
nozzle_temp_c     205

Echoing the fields before validating them sounds like a wasted stage until a key is misspelled. layer_hight_mm surfaces here as a KeyError on line eleven, not three functions deep inside a check that quietly found nothing to complain about. Two fields describe the model instead of a preference: overhangs and near_heat state where the part sits and what its geometry does, and the validator leans on both. One field is measured rather than chosen: hole_clearance_mm is 0.4 because of a coupon printed later in this chapter.

A table the printer has to get past

▣ Build · stage 2 — one spec table, one generic loop
MATERIAL_SPECS = {
    "PLA": {
        "layer_height_mm":   (0.12, 0.28),
        "wall_count":        (3, 6),
        "infill_percent":    (15, 40),
        "nozzle_temp_c":     (195, 220),
        "bed_temp_c":        (50, 65),
        "fan_percent":       (80, 100),
        "hole_clearance_mm": (0.2, 0.5),
    },
    "PETG": {
        "layer_height_mm":   (0.12, 0.28),
        "wall_count":        (3, 6),
        "infill_percent":    (15, 40),
        "nozzle_temp_c":     (230, 250),
        "bed_temp_c":        (70, 90),
        "fan_percent":       (30, 60),
        "hole_clearance_mm": (0.2, 0.5),
    },
}

def check_ranges(profile: dict) -> list[str]:
    material = str(profile.get("material", "")).upper()
    if material not in MATERIAL_SPECS:
        return [f"material {material!r} is not one of {sorted(MATERIAL_SPECS)}"]
    errors: list[str] = []
    for key, (low, high) in MATERIAL_SPECS[material].items():
        value = profile.get(key)
        if value is None:
            errors.append(f"missing setting: {key}")
        elif not low <= value <= high:
            errors.append(f"{key} = {value} outside {material} spec {low}-{high}")
    return errors

if __name__ == "__main__":
    draft = {"material": "pla", "layer_height_mm": 0.32, "wall_count": 4,
             "infill_percent": 20, "nozzle_temp_c": 205, "bed_temp_c": 60,
             "hole_clearance_mm": 0.4}
    for label, profile in (("outer_shell", load_profile(PROFILES / "outer_shell.json")),
                           ("draft copy", draft)):
        print(f"{label + ':':<14}{check_ranges(profile)}")
$ uv run python -m labs.print_check
outer_shell:  []
draft copy:   ['layer_height_mm = 0.32 outside PLA spec 0.12-0.28', 'missing setting: fan_percent']

No branch anywhere in check_ranges knows what PLA is. The material string selects a table of bounds and the loop walks whatever it finds there, so teaching the tool about ABS is one dict entry and no code, and the lowercase pla in the draft still finds its table because .upper() runs before the lookup. The unknown-material case returns early on purpose: with no known material there are no bounds to check against, and six missing-setting lines underneath would bury the one line that matters. Note the None guard before the comparison. A missing key has to become a sentence, and 0.12 <= None raises instead of returning False.

▣ Build · stage 3 — the flags a range cannot express
HEAT_SAFE = {"PETG"}
DECLARATIONS = ("overhangs", "near_heat")

def check_flags(profile: dict) -> list[str]:
    errors: list[str] = []
    for key in DECLARATIONS:
        if key not in profile:
            errors.append(f"missing declaration: {key} (true or false, no default)")
    if profile.get("overhangs") is True and profile.get("supports_enabled") is not True:
        errors.append("overhangs: true requires supports_enabled: true, got "
                      f"{profile.get('supports_enabled')!r}")
    if profile.get("near_heat") is True:
        material = str(profile.get("material", "")).upper()
        if material not in HEAT_SAFE:
            errors.append(f"near_heat: true requires {sorted(HEAT_SAFE)}, "
                          f"got {profile.get('material')!r}")
    return errors

def validate(profile: dict) -> list[str]:
    return check_ranges(profile) + check_flags(profile)

Ranges answer whether a number is sane. Flags answer whether you turned on something the geometry demands, and they are conditional: supports are wasted plastic on a flat plate and mandatory under a 60-degree overhang, so the requirement keys off a fact the profile states about the model. The presence loop is the part people leave out. A profile that never says whether the part has overhangs is a profile nobody finished, and reading a missing declaration as false is how an unsupported eye housing prints its ceiling as a hanging mess of strings. Both checks feed one list, so every problem in a file arrives in a single run.

▣ Build · stage 4 — a gate, not a report
import sys

def main() -> None:
    paths = sorted(PROFILES.glob("*.json"))
    if not paths:
        print(f"no profiles found in {PROFILES}")
        sys.exit(1)
    failed = 0
    for path in paths:
        errors = validate(load_profile(path))
        failed += bool(errors)
        print(f"[{'FAIL' if errors else 'PASS'}] {path.stem:<16}"
              f"{len(errors)} problem(s)")
        for err in errors:
            print(f"         x {err}")
    print(f"\n{len(paths) - failed} of {len(paths)} profiles cleared to slice")
    sys.exit(1 if failed else 0)

if __name__ == "__main__":
    main()
$ uv run python -m labs.print_check ; echo "exit: $?"
[PASS] base_plate      0 problem(s)
[PASS] eye_housing     0 problem(s)
[PASS] outer_shell     0 problem(s)
[FAIL] servo_bracket   2 problem(s)
         x layer_height_mm = 0.3 outside PLA spec 0.12-0.28
         x near_heat: true requires ['PETG'], got 'PLA'

3 of 4 profiles cleared to slice
exit: 1

The bracket profile was copied from the shell and edited halfway, which is how these files actually go wrong: PLA left next to a servo that will warm it, and 0.3 mm layers picked up from a draft profile too coarse for a part with a screw boss. Neither line came from wisdom. Both came from a table with the numbers written down. The exit code is what makes the tool a gate: uv run python -m labs.print_check && ./slice.sh servo_bracket never reaches the slicer while that file is wrong, and a shell that stops on a non-zero exit is the cheapest interlock in the project.

The number the printer has to tell you

One setting in every profile cannot be looked up: how much oversize a hole must be modeled so an M3 screw passes through it. An FDM hole always prints undersized. The nozzle lays a curved bead along the inside of a circle and the plastic pulls inward as it cools, so a hole modeled at exactly 3.0 mm arrives near 2.8 and the screw binds. The loss depends on your nozzle, your flow calibration and your filament, so the number belongs to your bench.

▣ Build · stage 5 — the coupon that answers it
# labs/coupon.py — a 40 x 15 x 4 mm bar with four holes, printed flat
SCREW_MM = 3.0
SLIP_FIT_MM = 0.05

def clearance_report(modeled: list[float], measured: list[float]) -> None:
    for want, got in zip(modeled, measured):
        fit = "slips" if got >= SCREW_MM + SLIP_FIT_MM else "binds"
        print(f"modeled {want:.2f}  measured {got:.2f}  lost {want - got:.2f}  M3 {fit}")

if __name__ == "__main__":
    clearance_report([3.00, 3.20, 3.40, 3.60], [2.78, 2.96, 3.19, 3.38])
$ uv run python -m labs.coupon   # measured on the bench — yours will vary
modeled 3.00  measured 2.78  lost 0.22  M3 binds
modeled 3.20  measured 2.96  lost 0.24  M3 binds
modeled 3.40  measured 3.19  lost 0.21  M3 slips
modeled 3.60  measured 3.38  lost 0.22  M3 slips

Twelve minutes of printing and four caliper readings replace an argument. This printer loses about 0.22 mm across every hole whatever its size, so 3.4 mm is the smallest modeled diameter an M3 passes through freely, and 0.4 goes into every profile as hole_clearance_mm. Print the coupon in PETG and the numbers move, so the field sits per part instead of in a constant at the top of the file. Keep the bar. It is the physical twin of the acceptance log from chapter 42: a measurement with a date on it, taken before the eight-hour print instead of after.

◆ Note — print the small thing first

Every part in this volume gets a coupon or a cut-down test before the full print: the bracket's ear on its own, the eye housing's bearing seat as a 20 mm disc, the base plate's corner with two mounting holes. Fifteen minutes and a gram of filament stand between you and a nine-hour reprint, and a test print is the only way to check a fit no dimension in your model can predict, like whether the LED ring drops into its recess.

Why this works: knowledge in a table, mechanism in a loop

The validator holds no material knowledge at all. Everything specific to PLA or PETG lives in one dict, and the code is a loop over whatever bounds the material names. That split keeps the tool alive as the build grows: adding ABS, or a flexible filament for a foot pad, is a data entry that touches no logic and can break no existing check. A chain of if material == ... branches instead grows a copy of the same five comparisons per material, and starts drifting the day one branch is edited and its siblings are not.

The second idea is the error list. Collecting problems into a list and returning it, instead of raising on the first one, is what makes the output above usable: two lines about the bracket, both true at once, so one edit fixes the file completely. A validator that raises makes you play a guessing game one round at a time. It is the same pattern the config preflight used in volume 4, and it transfers directly because the situation is identical. An untrusted document arrives from a human, and something expensive is about to happen downstream.

What makes this a gate instead of a linter is the exit code. A report tells you the bracket is wrong; a process that exits 1 stops the slicer from ever seeing the file. Put the check in front of the expensive step, make failure the default whenever anything is unclear, and the machine holds the standard so you can stop remembering it.

⚠ Worked failure — a PASS, and a bracket that snaps anyway

The gate goes green on all four profiles. You fix the bracket to PETG at 240, run the check, get a clean exit, then slice it in the slicer window that has been open since yesterday and print it. The ear cracks along a layer line as the screw goes in, and you can pull the break apart with a fingernail. That is textbook cold-layer delamination, on a part whose profile says 240 degrees. So look at what the printer was actually told, in the header the slicer writes into the gcode:

$ grep -E "^; (nozzle_temperature|layer_height|perimeters) =" gcode/servo_bracket.gcode
; nozzle_temperature = 205
; layer_height = 0.3
; perimeters = 3

Those are the shell's PLA settings, not the bracket's. The validator was reading configs/print_profiles/servo_bracket.json and the slicer was reading its own stored profile, and the two documents never met. Nothing was wrong with the check; the check was checking a file that had no authority over anything. The fix is to make the validated file the only source the printer can be driven from:

def slicer_args(profile: dict) -> list[str]:
    return [
        "--layer-height", str(profile["layer_height_mm"]),
        "--perimeters", str(profile["wall_count"]),
        "--fill-density", f"{profile['infill_percent']}%",
        "--nozzle-temperature", str(profile["nozzle_temp_c"]),
        "--bed-temperature", str(profile["bed_temp_c"]),
        "--support-material" if profile["overhangs"] else "--no-support-material",
    ]
$ grep -E "^; (nozzle_temperature|layer_height|perimeters) =" gcode/servo_bracket.gcode
; nozzle_temperature = 240
; layer_height = 0.2
; perimeters = 4

Generate the command line from the profile and the gap closes, because there is no second set of numbers left for the two to disagree about. Carry the general lesson out of the printer room: a validator protects only the path that actually reads what it validated. The way to find out whether yours does is to look at what the far end received.

Checkpoint, and a part that has to move

✓ Checkpoint — what you can now do
  • I can say why a servo bracket gets PETG and an outer shell does not, in degrees.
  • I can explain why adding a wall does more for a bracket than doubling infill, and why a bracket ear is printed lying flat.
  • I can add a material to the validator without editing check_ranges, and say what that proves about where the knowledge lives.
  • I know why a missing overhangs declaration is an error instead of a default of false.
  • I can measure a coupon and turn four caliper readings into the hole clearance my own printer needs.
  • Handed a PASS from the validator and a delaminated part, I know to read the gcode header before doubting either the profile or the printer.
⚡ Exercises — try first, then reveal
Exercise 1 — warn before it fails. Add warnings(profile) that flags any value sitting in the outer ten percent of its allowed range, and print the notes under each PASS line without changing the exit code.

For each bound, compute band = (high - low) * 0.10 and report values at or below low + band or at or above high - band. Run it against the shell and fan_percent = 100 comes back sitting on the high limit, which is true and fine. That is the design question the exercise forces: a warning that cannot fail a build earns its place only by being rare, so anything you ignore twice belongs in the spec table as a real bound or nowhere.

Exercise 2 — check the gcode, not the plan. Write verify_gcode(profile, gcode_path) that parses the slicer's header comments and reports any setting that disagrees with the profile it claims to implement.

Read the leading ; key = value lines into a dict, map the three or four you care about onto profile keys, and compare as floats so 205 and 205.0 agree. Against a file sliced from the wrong profile it prints the mismatch the worked failure found with grep, except now it runs every time instead of when you think of it. Bolt it onto the end of the slice script and the loop closes.

Exercise 3 — print the coupon and set your own number. Model a 40 by 15 by 4 mm bar with holes at 3.0, 3.2, 3.4 and 3.6 mm, print it flat, measure each hole with a caliper and feed your readings to clearance_report.

Measure across two axes per hole and take the larger loss, since a hole printed on a moving bed comes out slightly out of round along the direction of travel. Set hole_clearance_mm in all four profiles from the smallest modeled diameter that slips, commit it with the caliper readings in the message, then print the bar again in PETG. Two numbers on one small coupon, and the screws in the rest of this volume fit on the first try.

Four profiles clear the gate, a coupon says what this printer does to a hole, and the parts can go on the bed while you carry on reading. What comes off it is plastic that does nothing. Chapter 64 puts the first moving thing inside it: a hobby servo, which takes no notion of degrees at all and obeys only the width of a repeating pulse. You will do that arithmetic on paper before any current reaches a motor bolted into a bracket you printed yourself.