""" Anchorage's furnishing (2026-10-21). The user, that day: "2- There is a another asset "...\\scifi-fan-convector-heater.zip" put it meaningfully inside the facility in the corners couple of them. 3- add and place nutrition feeder to the facility walls to the meaningfully 4- place couple robots 2 inside 2 outside 1 next to one of the generators use this robot model asset in this path "...\\VaultCache\\Robotscob94d93332ad0V1" 5- use creates and tools from Modular Scifi Season 2 Starter Bundle meaningfully place it inside anchorage. also use" nine more models (Assets\\, the Fab library): containers, a crate, a lantern, an irrigation robot, a battery, a cart apply(report=print) brings the models into the open file (once) and places everything in SPEC, in place remove(report=print) takes the placed things out again (the models stay, out of the view layer) check(report=print) what is placed, as it now stands in the file, against the station and each other cut_walls() behind each feeder, a boolean on the wall so its hole shows the tunnel (apply does it) blender -b --factory-startup --python build/furnish.py -- [--apply] [--check] [--save] The models are collections kept out of the view layer, under "PROPS | furnishing models": props source_fab//.blend (build/props_prepare.py): the fan heater, the user's nine models, the nutrition feeder of models/nutrition-feeder robots source_fab/robot_scout/robot_scout.blend (build/robot_prepare.py): four posed robots of the Robot_scout_R_21 pack Season 2 source_fab/season2/season2.blend (build/season2_prepare.py): the pack's cases, pallets, drums, tools, tanks, placed as kit meshes (["ue_mesh"]): Unreal spawns the packs' own assets Props and robots are placed as instances of their collection, the Season 2 pieces as objects sharing the kit mesh. What goes where is SPEC; how each place is found is its rule (a wall, a corner, the floor, on another prop, at a prop a robot works on, in a cooler's own frame). Every place is then checked with the models' own faces: against the station and the coolers, against each other (except what stands on what), the floor under it, the wall behind a wall unit (flat, not glass, and nothing behind it that the feeder's 12.3 cm tunnel would show in), and the walking lanes (LANES), which only robots may stand in. """ import math import os import sys import bpy from mathutils import Matrix, Vector from mathutils.bvhtree import BVHTree sys.dont_write_bytecode = True HERE = os.path.dirname(os.path.abspath(__file__)) if HERE not in sys.path: sys.path.insert(0, HERE) import furnish_geo as fg # noqa: E402 from bl_kit import ROOT, exclude_collection, get_collection # noqa: E402 STATION = "ANCHORAGE STATION" COLLECTION = "13 FURNISHING" SOURCES = "PROPS | furnishing models (kept out of the view layer)" SRC = os.path.join(ROOT, "source_fab") FLOOR_Z = 0.05 # the station's floor (build_anchorage.STATION_FLOOR_Z) GROUPS = {"heaters": "13 FURNISHING | fan heaters", "feeders": "13 FURNISHING | nutrition feeders", "robots": "13 FURNISHING | robots", "cargo": "13 FURNISHING | cargo and tools"} # key: (blend under source_fab, its collection, what Unreal gets: "fab:" a model to import, "robot", or the FBX) MODELS = { "fan_heater": ("fan_heater/fan_heater.blend", "PROP | Fan convector heater", "fab:fan_heater"), "triangle_container": ("triangle_container/triangle_container.blend", "PROP | Triangle container", "fab:triangle_container"), "crate_container_a": ("crate_container_a/crate_container_a.blend", "PROP | Crate container A", "fab:crate_container_a"), "crate_container_b": ("crate_container_b/crate_container_b.blend", "PROP | Crate container B", "fab:crate_container_b"), "crate_container_c": ("crate_container_c/crate_container_c.blend", "PROP | Crate container C", "fab:crate_container_c"), "epsilon_container": ("epsilon_container/epsilon_container.blend", "PROP | Epsilon container", "fab:epsilon_container"), "scifi_crate": ("scifi_crate/scifi_crate.blend", "PROP | Scifi crate", "fab:scifi_crate"), "container_005": ("container_005/container_005.blend", "PROP | Scifi container 005", "fab:container_005"), "lantern": ("lantern/lantern.blend", "PROP | Lantern", "fab:lantern"), "irrigation_robot": ("irrigation_robot/irrigation_robot.blend", "PROP | Irrigation control robot", "fab:irrigation_robot"), "battery": ("battery/battery.blend", "PROP | Portable battery", "fab:battery"), "assist_cart": ("assist_cart/assist_cart.blend", "PROP | Assist cart", "fab:assist_cart"), "nutrition_feeder": ("nutrition_feeder/nutrition_feeder.blend", "PROP | Nutrition feeder", "fbx:models/nutrition-feeder/fbx"), "robot_cooler": ("robot_scout/robot_scout.blend", "PROP | Robot scout R21 cooler", "robot"), "robot_patrol": ("robot_scout/robot_scout.blend", "PROP | Robot scout R21 patrol", "robot"), "robot_supply": ("robot_scout/robot_scout.blend", "PROP | Robot scout R21 supply", "robot"), "robot_hall": ("robot_scout/robot_scout.blend", "PROP | Robot scout R21 hall", "robot"), } SEASON2 = ("season2/season2.blend", "KIT | Modular SciFi Season 2 (Anchorage's crates and tools)") # the extinguisher hangs on its mount: same origin, turned half round (the packs' own demo maps do it so) SETS = {"extinguisher": (("EP_SM_FireExtinguisher_Mount", 0.0), ("EP_SM_FireExtinguisher", 180.0))} DIRS = {"N": Vector((0, 1, 0)), "S": Vector((0, -1, 0)), "E": Vector((1, 0, 0)), "W": Vector((-1, 0, 0))} # a model's front is its -Y; with its back to a wall that lies north of it, it faces south: yaw 0 YAW_BACK_TO = {"N": 0.0, "S": 180.0, "E": -90.0, "W": 90.0} # Walking lanes kept clear (x0, x1, y0, y1): the station's axis from the arrivals door to the boarding bridge, the halls, # the quays, the passages of the side wings and the way through the supplies hall to them LANES = { "main axis": (-1.1, 1.1, -36.0, 40.5), "cargo hall": (4.7, 20.0, -1.1, 1.1), "west hall": (-15.5, -4.7, -1.1, 1.1), "east quay": (5.0, 42.0, -28.2, -26.0), # the north side is where cargo for the vessel stands "west quay": (-42.0, -5.0, -26.4, -23.6), "security wing": (-6.7, -5.4, 14.4, 26.6), # its north end is a dead end "east passage": (5.4, 6.6, 14.4, 19.2), "supplies hall": (-6.6, 6.6, 15.9, 17.1), "dock head": (-5.0, 5.0, -26.4, -23.6), # the way between the quays } Z_FEEDER = 0.85 # the feeder's origin (the panel's bottom edge) above the floor: its cavity at 1.03 m Z_EXTINGUISHER = 0.62 # the mount's foot above the floor: the extinguisher's handle at 1.2 m, its foot clear # of the walls' recessed skirting def S(id, what, group, room, rule, at, yaw=0.0, **kw): d = dict(id=id, what=what, group=group, room=room, rule=rule, at=at, yaw=yaw) d.update(kw) return d # id, what (MODELS key, "s2:", "set:"), group, room, rule, at (x, y), yaw (deg), extra: # rule floor z from the floor under it # wall: its back to the wall that lies in direction D (N S E W) of `at`, z above the floor; # search: how far along the wall to look for a flat stretch (m) # corner: its back to the wall in direction A, its side to the wall in direction B # on: standing on that prop; `at` in the prop's frame, yaw added to its yaw # reach: a robot facing that prop's front, its fingertips on it; `at` = (sideways, height) on the face # frame: `at`, yaw in that object's frame (a cooler), z from what is under it # outside the floor is the ice: rays from high up SPEC = [ # -- fan convector heaters: in corners of the processing hall and its two side wings. Anchorage is automated, nobody # lives there (GameLore.txt); the convict passes through. There is no detention (the user, 2026-10-21; they had # taken the DETENTION sign out of the station by hand): the east wing is the one the MEDICAL sign leads into ------ S("heater | medical wing, north end, north-east corner", "fan_heater", "heaters", "02 PROCESSING", "corner:NE", (11.4, 31.4)), S("heater | security wing, north-west corner", "fan_heater", "heaters", "02 PROCESSING", "corner:NW", (-7.9, 26.6)), S("heater | processing hall, north-west corner", "fan_heater", "heaters", "02 PROCESSING", "corner:WN", (-4.1, 31.8)), S("heater | medical wing, south-east corner", "fan_heater", "heaters", "02 PROCESSING", "corner:ES", (11.4, 19.7)), # -- nutrition feeders: one per room or corridor (the user, 2026-10-21: "reduce the number of feeders one per room or # corridor"; there were six, two in each room): the convict's first ration after the cryo sleep in the processing # hall, one in each of its side wings. The processing hall's flat wall is its west one (the north side is the # arrivals doorway, the east side two doorways and ribbed panels); the halls' walls are slanted and take none S("feeder | processing hall, west wall", "nutrition_feeder", "feeders", "02 PROCESSING", "wall:W", (-4.0, 28.8), search=0.3), S("feeder | medical wing, east wall", "nutrition_feeder", "feeders", "02 PROCESSING", "wall:E", (11.2, 21.0), search=1.0), # facing the wing's door from the processing hall S("feeder | security wing, west wall", "nutrition_feeder", "feeders", "02 PROCESSING", "wall:W", (-8.2, 24.4), search=0.6), # -- cargo and tools ------------------------------------------------------------------------------------------------- # supplies hall, west bay (the security wing's south end): a container being checked, two more against the walls S("container | supplies hall, west bay", "container_005", "cargo", "02 PROCESSING", "wall:W", (-8.0, 15.6)), S("epsilon container | supplies hall, west bay", "epsilon_container", "cargo", "02 PROCESSING", "wall:W", (-8.0, 19.9)), S("triangle container | supplies hall, west bay", "triangle_container", "cargo", "02 PROCESSING", "wall:W", (-8.0, 17.5)), # supplies hall, east bay: a pallet of cases against the east wall, a container beside it S("pallet | supplies hall, east bay, south", "s2:EP_SM_PlasticPallet", "cargo", "02 PROCESSING", "corner:ES", (8.0, 15.2)), S("case | on the south pallet", "s2:EP_SM_Case_A", "cargo", "02 PROCESSING", "on:pallet | supplies hall, east bay, south", (0.0, 0.25), 90.0), S("case | on the south pallet, stacked", "s2:EP_SM_Case_A", "cargo", "02 PROCESSING", "on:case | on the south pallet", (0.0, 0.0), 4.0), S("lantern | on the stacked case", "lantern", "cargo", "02 PROCESSING", "on:case | on the south pallet, stacked", (0.15, 0.10), 25.0), S("crate container | supplies hall, east bay", "crate_container_b", "cargo", "02 PROCESSING", "wall:E", (8.0, 17.5)), # dock head (departures): drinking water for the vessel on a pallet in its south-east corner, by the bridge S("pallet | dock head", "s2:EP_SM_PlasticPallet", "cargo", "07 DOCK (head, boarding bridge, quays)", "floor", (2.6, -27.45), 0.0), S("water drum | dock head pallet 1", "s2:EP_SM_WaterDrum", "cargo", "07 DOCK (head, boarding bridge, quays)", "on:pallet | dock head", (-0.30, -0.30), 0.0), S("water drum | dock head pallet 2", "s2:EP_SM_WaterDrum", "cargo", "07 DOCK (head, boarding bridge, quays)", "on:pallet | dock head", (0.30, -0.30), 35.0), S("water drum | dock head pallet 3", "s2:EP_SM_WaterDrum", "cargo", "07 DOCK (head, boarding bridge, quays)", "on:pallet | dock head", (-0.30, 0.30), 70.0), S("water drum | dock head pallet 4", "s2:EP_SM_WaterDrum", "cargo", "07 DOCK (head, boarding bridge, quays)", "on:pallet | dock head", (0.30, 0.30), 10.0), # the room south of the supply hall, with the supply racks: the cart and a cable reel S("assist cart | rack room", "assist_cart", "cargo", "03 CENTRAL CROSSING", "floor", (-2.6, 7.0), -90.0), S("wire reel | rack room", "s2:EP_SM_WireReel_A", "cargo", "03 CENTRAL CROSSING", "floor", (2.7, 6.3), 8.0), # cargo hall, east end: work at the sealed maintenance door, on the junction's floor between its corner consoles S("rubber mat | maintenance door", "s2:EP_SM_RubberMat_Flat", "cargo", "05 CARGO hall", "floor", (18.55, 1.15), lane_ok=True), S("toolbox | on the mat", "s2:EP_SM_Toolbox", "cargo", "05 CARGO hall", "on:rubber mat | maintenance door", (-0.75, 0.55), 12.0), S("screwdriver | on the mat", "s2:EP_SM_Screwdriver", "cargo", "05 CARGO hall", "on:rubber mat | maintenance door", (-0.25, 0.32), 40.0), S("wrench | on the mat", "s2:EP_SM_Wrench", "cargo", "05 CARGO hall", "on:rubber mat | maintenance door", (-0.42, 0.78), -25.0), S("rolled mat | maintenance door", "s2:EP_SM_RubberMat_Rolled", "cargo", "05 CARGO hall", "floor", (16.55, 1.65), 0.0), S("work light | maintenance door", "s2:EP_SM_PortableLight", "cargo", "05 CARGO hall", "floor", (18.05, -2.1), 150.0), # its cables lie across the floor to the door; only the unit stands off the lane S("battery | maintenance door", "battery", "cargo", "05 CARGO hall", "floor", (16.3, -1.1), 0.0, lane_ok=True), S("extinguisher | maintenance door", "set:extinguisher", "cargo", "05 CARGO hall", "wall:N", (18.05, 2.2), z=Z_EXTINGUISHER), # mooring gallery: the irrigation robot and its plant by the windows S("irrigation robot | mooring gallery", "irrigation_robot", "cargo", "06 MOORING GALLERY", "wall:E", (3.3, -9.0)), # east quay: cargo for the vessel, staged along the north windows (the walkway is the south side) S("barrels | east quay", "crate_container_c", "cargo", "07 DOCK (head, boarding bridge, quays)", "floor", (13.7, -23.6), 90.0), S("barrel | east quay", "crate_container_a", "cargo", "07 DOCK (head, boarding bridge, quays)", "floor", (17.0, -22.6), 0.0), S("crate container | east quay", "crate_container_b", "cargo", "07 DOCK (head, boarding bridge, quays)", "floor", (18.7, -22.7), 0.0), S("netted crate | east quay", "scifi_crate", "cargo", "07 DOCK (head, boarding bridge, quays)", "floor", (22.2, -23.0), 0.0), S("pallet | east quay", "s2:EP_SM_PlasticPallet", "cargo", "07 DOCK (head, boarding bridge, quays)", "floor", (24.8, -22.7), 0.0), S("case | east quay pallet", "s2:EP_SM_Case_A", "cargo", "07 DOCK (head, boarding bridge, quays)", "on:pallet | east quay", (0.0, -0.30), 0.0), S("oxygen tank | east quay pallet 1", "s2:EP_SM_OxygenTank", "cargo", "07 DOCK (head, boarding bridge, quays)", "on:pallet | east quay", (-0.55, 0.50), 0.0), S("oxygen tank | east quay pallet 2", "s2:EP_SM_OxygenTank", "cargo", "07 DOCK (head, boarding bridge, quays)", "on:pallet | east quay", (-0.25, 0.55), 30.0), S("nitrogen tank | east quay pallet 1", "s2:EN_SM_NitrogenTank", "cargo", "07 DOCK (head, boarding bridge, quays)", "on:pallet | east quay", (0.25, 0.50), 0.0), S("nitrogen tank | east quay pallet 2", "s2:EN_SM_NitrogenTank", "cargo", "07 DOCK (head, boarding bridge, quays)", "on:pallet | east quay", (0.58, 0.52), 50.0), # extinguishers by the main doors S("extinguisher | arrivals door", "set:extinguisher", "cargo", "01 ARRIVALS lock", "wall:N", (3.0, 39.0), z=Z_EXTINGUISHER), S("extinguisher | processing counter", "set:extinguisher", "cargo", "02 PROCESSING", "wall:S", (-4.2, 23.4), z=Z_EXTINGUISHER), S("extinguisher | boarding bridge", "set:extinguisher", "cargo", "07 DOCK (head, boarding bridge, quays)", "wall:E", (3.3, -32.0), z=Z_EXTINGUISHER), # -- robots: two inside, two outside, one of them at an ice cooler (after the props: one works at one) ------------- # the cooler's screen (its LOCK panel 1.0-1.1 m above the round deck) faces south; in the cooler's own frame S("robot | at the south-west ice cooler's screen", "robot_cooler", "robots", "12 ICE COOLERS", "frame:ICE COOLER | south-west", (-0.15, -2.63), 180.0), S("robot | on the ice, walking to the south-west cooler", "robot_patrol", "robots", "12 ICE COOLERS", "floor", (-88.0, 63.0), -116.6, outside=True), S("robot | supplies hall, at a container's screen", "robot_supply", "robots", "02 PROCESSING", "reach:container | supplies hall, west bay", (0.0, 0.0)), S("robot | cargo hall, walking to the maintenance door", "robot_hall", "robots", "05 CARGO hall", "floor", (12.0, -0.6), 90.0), ] # --------------------------------------------------------------------------------------------------------------------- # models # --------------------------------------------------------------------------------------------------------------------- def _append_collection(path, name): col = bpy.data.collections.get(name) if col is not None: return col with bpy.data.libraries.load(path, link=False, relative=True) as (src, dst): if name not in src.collections: raise RuntimeError("no collection %r in %s" % (name, path)) dst.collections = [name] return dst.collections[0] def models(report=print): """The models' collections in the open file, appended the first time, under SOURCES (out of the view layer).""" parent = get_collection(SOURCES) want = {k: v for k, v in MODELS.items()} out = {} for key, (rel, name, _ue) in want.items(): col = _append_collection(os.path.join(SRC, rel.replace("/", os.sep)), name) if col.name not in parent.children: parent.children.link(col) out[key] = col s2 = _append_collection(os.path.join(SRC, SEASON2[0].replace("/", os.sep)), SEASON2[1]) if s2.name not in parent.children: parent.children.link(s2) for o in s2.objects: o.data.use_fake_user = True exclude_collection(parent.name, True) out["season2"] = s2 return out def _dead(im): return (im is not None and im.source == "FILE" and im.filepath and im.packed_file is None and not os.path.isfile(bpy.path.abspath(im.filepath))) def clean_materials(report=print): """No missing files from the models: the Fab models built with DecalMachine (Lantern, ScifiContainer_005) name decal atlases and parallax height maps on their author's disk, which the Fab packages do not hold. props_prepare.py cut their links, so they render as nothing; here each such image becomes a 1 x 1 generated placeholder, and the material graphs stay exactly as prepared. (Taking the dead nodes out instead, on 2026-10-21, made EEVEE hang for good on ScifiContainer_005 in the station, though the same graphs rendered on their own: see restore_model.)""" n = 0 for im in bpy.data.images: if _dead(im): im.source = "GENERATED" im.generated_type = "BLANK" im.generated_width = im.generated_height = 1 im.generated_color = (0.0, 0.0, 0.0, 0.0) im.filepath = "" n += 1 report("[furnish] materials: %d images that name files on their author's disk made 1 x 1 placeholders" % n) return n def restore_model(key, report=print, placeholders=True): """A model's collection in the open file put back as source_fab/ has it: the old one and what only it used go, the fresh one comes in, and its instances are pointed at it (they keep their places). placeholders=False leaves its dead images missing, as prepared.""" rel, name, _ue = MODELS[key] parent = get_collection(SOURCES) old = bpy.data.collections.get(name) users = [o for o in bpy.data.objects if o.instance_type == "COLLECTION" and o.instance_collection == old] if old else [] if old is not None: for o in list(old.all_objects): bpy.data.objects.remove(o) for c in list(old.children_recursive): bpy.data.collections.remove(c) bpy.data.collections.remove(old) bpy.data.orphans_purge(do_local_ids=True, do_linked_ids=False, do_recursive=True) new = _append_collection(os.path.join(SRC, rel.replace("/", os.sep)), name) if new.name not in parent.children: parent.children.link(new) for o in users: o.instance_collection = new exclude_collection(parent.name, True) _SHAPES.pop(key, None) report("[furnish] %s put back as prepared (%d objects); %d instances pointed at it" % ( name, len(new.all_objects), len(users))) if placeholders: clean_materials(report) return len(users) def season2_mesh(piece): col = bpy.data.collections[SEASON2[1]] for o in col.objects: if o.name == "KIT | " + piece or o.name.startswith("KIT | " + piece + "."): return o.data raise KeyError(piece) class Shape(object): """A model's faces in its own frame (for the checks) and its box. `clash_polys`: the faces the clash test uses (the feeder's tunnel, which is meant to sit in the wall, is left out of it).""" def __init__(self, verts, polys, clash_polys=None): self.verts, self.polys = verts, polys self.clash_polys = polys if clash_polys is None else clash_polys self.lo, self.hi = fg._aabb(verts) if verts else (Vector(), Vector()) def world_tree(self, m): return BVHTree.FromPolygons([m @ v for v in self.verts], self.clash_polys, all_triangles=False) _SHAPES = {} def _world(o): m = o.matrix_basis.copy() while o.parent is not None: m = o.matrix_parent_inverse @ m o = o.parent m = o.matrix_basis @ m return m def footprint(what, shape): """Where the model rests: a robot on its feet (its reaching arm may be over something), the rest on its box.""" if what.startswith("robot_"): return Vector((-0.25, -0.35, 0.0)), Vector((0.25, 0.35, 0.0)) return shape.lo, shape.hi def shape_of(what): if what in _SHAPES: return _SHAPES[what] verts, polys = [], [] def add(me, m): base = len(verts) verts.extend(m @ v.co for v in me.vertices) polys.extend(tuple(base + i for i in p.vertices) for p in me.polygons) if what.startswith("s2:"): add(season2_mesh(what[3:]), Matrix.Identity(4)) elif what.startswith("set:"): for piece, yaw in SETS[what[4:]]: add(season2_mesh(piece), Matrix.Rotation(math.radians(yaw), 4, "Z")) else: col = bpy.data.collections[MODELS[what][1]] off = Matrix.Translation(-col.instance_offset) for o in col.all_objects: if o.type != "MESH" or o.hide_render: continue # the models' collections are out of the view layer, so their objects' world matrices are not kept up # to date: worked out from the parents me = o.to_mesh() add(me, off @ _world(o)) o.to_mesh_clear() clash = None if what == "nutrition_feeder": clash = [p for p in polys if all(verts[i].y <= 0.002 for i in p)] _SHAPES[what] = Shape(verts, polys, clash) return _SHAPES[what] # --------------------------------------------------------------------------------------------------------------------- # finding the places # --------------------------------------------------------------------------------------------------------------------- def _floor_at(g, x, y, outside=False, near=FLOOR_Z): """The floor under (x, y): rays from 0.5 m over the floor (so a ledge or a console higher up is not taken for it). A ray that falls through a seam between floor pieces (to the ice under the station) is tried again 5 cm aside.""" best = None for dx, dy in ((0.0, 0.0), (0.05, 0.0), (0.0, 0.05), (-0.05, 0.0), (0.0, -0.05)): h = g.floor(x + dx, y + dy, 60.0 if outside else near + 0.5, below=80.0 if outside else 1.5) if h is None: continue if outside or h[0] > near - 0.3: return h[0] best = h[0] if best is None else max(best, h[0]) return best def _rest(zs, outside): """The height a model rests at, from the floor under its footprint. The station's floors are panels a few cm above or below each other (the quays: -2 to +10 cm): indoors a model takes the middle of what is under it, its edges may stand on a lower panel or over a groove; on the ice it stands on the highest point.""" zs = sorted(zs) return zs[-1] if outside else zs[len(zs) // 2] def _support(g, m, shape, outside=False, what=""): """Floor heights under the model's footprint corners and middle (inset 6 cm).""" lo, hi = footprint(what, shape) lo = Vector((lo.x, lo.y, shape.lo.z)) pts = [] for fx, fy in ((0.5, 0.5), (0.0, 0.0), (1.0, 0.0), (0.0, 1.0), (1.0, 1.0)): x = lo.x + 0.06 + (hi.x - lo.x - 0.12) * fx y = lo.y + 0.06 + (hi.y - lo.y - 0.12) * fy p = m @ Vector((x, y, lo.z)) z = _floor_at(g, p.x, p.y, outside, near=p.z) pts.append(z) return pts def _wall_hit(g, p, d, reach=3.0): h = g.ray(p, d, reach) if h is None: return None if h[2].dot(-d) < math.cos(math.radians(15.0)): # not a wall square to the ray return None return h def _flat(g, origin, d, tangent, w0, w1, z0, z1, back, nx=5, nz=5): """Is the wall flat and solid behind a face from w0..w1 along the tangent and z0..z1 high? (all hits 25 cm out)""" want = 0.25 for i in range(nx): for j in range(nz): s = w0 + (w1 - w0) * (i / (nx - 1.0)) z = z0 + (z1 - z0) * (j / (nz - 1.0)) p = origin + tangent * s + Vector((0, 0, z)) + d * (back - want) h = g.ray(p, d, want + 0.05) if h is None or abs(h[0] - want) > 0.012 or h[2].dot(-d) < math.cos(math.radians(10.0)): return False, "not flat at (%.2f along, %.2f up)" % (s, z) if any(k in h[4].lower() for k in ("glass", "window")): return False, "glass behind it (%s)" % h[4] return True, "" def resolve(spec, g, placed, report=print): """The world matrix for one SPEC entry, or (None, why).""" what, rule, (ax, ay), yaw = spec["what"], spec["rule"], spec["at"], spec["yaw"] shape = shape_of(what) outside = spec.get("outside", False) kind, _, arg = rule.partition(":") if kind == "floor": m = fg.yaw_matrix(ax, ay, 0.0, yaw) zs = [z for z in _support(g, m, shape, outside, what) if z is not None] if not zs: return None, "no floor under it" return fg.yaw_matrix(ax, ay, _rest(zs, outside), yaw), "" if kind == "frame": frame = bpy.data.objects.get(arg) if frame is None: return None, "no object %r" % arg fm = frame.matrix_world p = fm @ Vector((ax, ay, 0.0)) fyaw = math.degrees(fm.to_euler().z) m = fg.yaw_matrix(p.x, p.y, 0.0, fyaw + yaw) zs = [z for z in _support(g, m, shape, True, what) if z is not None] if not zs: return None, "nothing under it" return fg.yaw_matrix(p.x, p.y, _rest(zs, True), fyaw + yaw), "" if kind == "on": base = placed.get(arg) if base is None: return None, "what it stands on (%s) is not placed" % arg bm = base["matrix"] top = shape_of(base["spec"]["what"]).hi.z return bm @ Matrix.Translation((ax, ay, top)) @ Matrix.Rotation(math.radians(yaw), 4, "Z"), "" if kind == "reach": base = placed.get(arg) if base is None: return None, "what it works at (%s) is not placed" % arg bm = base["matrix"] bshape = shape_of(base["spec"]["what"]) col = bpy.data.collections[MODELS[what][1]] tip = Vector(col["robot_reach_point"]) # the fingertips 2 cm off the prop's front face, `at` sideways along it; the robot turned to face it target_local = Vector((ax, bshape.lo.y - 0.02, 0.0)) t = bm @ target_local ryaw = math.degrees(bm.to_euler().z) + 180.0 r = Matrix.Rotation(math.radians(ryaw), 4, "Z") tip_w = r @ Vector((tip.x, tip.y, 0.0)) p = Vector((t.x - tip_w.x, t.y - tip_w.y, 0.0)) m = fg.yaw_matrix(p.x, p.y, 0.0, ryaw) zs = [z for z in _support(g, m, shape, False, what) if z is not None] if not zs: return None, "no floor under it" return fg.yaw_matrix(p.x, p.y, _rest(zs, False), ryaw), "" if kind == "wall" and what != "nutrition_feeder": # standing on the floor with its back to the wall, or an extinguisher's mount hung on it: the nearest of the # wall's faces behind its back sets it (a slanted or ribbed wall then touches it at its most forward part) d = DIRS[arg] tangent = Vector((-d.y, d.x, 0.0)) yaw = YAW_BACK_TO[arg] + spec.get("turn", 0.0) rot = Matrix.Rotation(math.radians(yaw), 4, "Z") floor = _floor_at(g, ax, ay) if floor is None: return None, "no floor at the seed" hung = what.startswith("set:") zm = spec.get("z", 0.0) if hung else 0.0 w = sorted((rot @ Vector((x, 0, 0))).dot(tangent) for x in (shape.lo.x, shape.hi.x)) best = None for s in (w[0] + 0.03, 0.0, w[1] - 0.03): for z in (0.08, shape.hi.z * 0.5, shape.hi.z - 0.05): h = g.ray(Vector((ax, ay, floor + zm + z)) + tangent * s, d, 3.0) if h is not None and (best is None or h[0] < best): best = h[0] if best is None: return None, "no wall within 3 m" o = Vector((ax, ay, 0.0)) + d * (best - shape.hi.y - (0.005 if hung else 0.02)) o.z = floor + zm if not hung: zs = [z for z in _support(g, Matrix.Translation(o) @ rot, shape, False, what) if z is not None] o.z = _rest(zs, False) if zs else floor return Matrix.Translation(o) @ rot, "" if kind == "wall": d = DIRS[arg] tangent = Vector((-d.y, d.x, 0.0)) yaw = YAW_BACK_TO[arg] + spec.get("turn", 0.0) floor = _floor_at(g, ax, ay) if floor is None: return None, "no floor at the seed" zm = spec.get("z", Z_FEEDER if what == "nutrition_feeder" else 0.0) back = shape.hi.y if what != "nutrition_feeder" else 0.0 # the feeder's origin is on the wall's face # its face on the wall, in the wall's frame: x along the tangent, z up zf0, zf1 = zm + shape.lo.z + 0.03, zm + shape.hi.z - 0.03 probe = floor + (zf0 + zf1) / 2.0 search, step = spec.get("search", 0.6), 0.1 offs = [0.0] k = 1 while k * step <= search + 1e-6: offs += [k * step, -k * step] k += 1 why = "no wall" rot = Matrix.Rotation(math.radians(yaw), 4, "Z") for s in offs: seed = Vector((ax, ay, probe)) + tangent * s h = _wall_hit(g, seed, d) if h is None: why = "no square wall within 3 m" continue face = h[1] # the model's own x runs along -tangent or +tangent depending on its turn: take its extent both ways ex = [rot @ Vector((shape.lo.x, 0, 0)), rot @ Vector((shape.hi.x, 0, 0))] w = sorted(e.dot(tangent) for e in ex) ok, why = _flat(g, Vector((face.x, face.y, floor)), d, tangent, w[0] + 0.02, w[1] - 0.02, zf0, zf1, 0.0) if not ok: continue o = Vector((face.x, face.y, 0.0)) - d * (back + 0.01) o.z = floor + zm m = Matrix.Translation(o) @ rot if what == "nutrition_feeder": ok, why = _behind_clear(g, m, d, h[3]) if not ok: continue return m, ("moved %.1f m along the wall" % s) if s else "" return None, why if kind == "corner": a, b = DIRS[arg[0]], DIRS[arg[1]] yaw = YAW_BACK_TO[arg[0]] floor = _floor_at(g, ax, ay) if floor is None: return None, "no floor at the seed" rot = Matrix.Rotation(math.radians(yaw), 4, "Z") ha = hb = None for z in (0.9, 0.6, 0.3, 1.2): # a wall square to the ray at one of these heights if z > shape.hi.z + 0.3: continue ha = ha or _wall_hit(g, Vector((ax, ay, floor + z)), a) hb = hb or _wall_hit(g, Vector((ax, ay, floor + z)), b) if ha is None or hb is None: return None, "no square wall %s" % ("behind" if ha is None else "beside") back = shape.hi.y # the model's half width towards the side wall side = max((rot @ Vector((shape.lo.x, 0, 0))).dot(b), (rot @ Vector((shape.hi.x, 0, 0))).dot(b)) # each wall where it stands furthest out over the model's height (skirting, ribs, a lining's edge) wa, wb = ha[1].dot(a), hb[1].dot(b) for z in (0.06, shape.hi.z * 0.33, shape.hi.z * 0.66, shape.hi.z - 0.05): for s in (-0.6, -0.3, 0.0, 0.3): h = g.ray(Vector((ax, ay, floor + z)) + b * (s * side * 2.0), a, 3.0) if h is not None: wa = min(wa, h[1].dot(a)) h = g.ray(Vector((ax, ay, floor + z)) + a * (s * back * 2.0), b, 3.0) if h is not None: wb = min(wb, h[1].dot(b)) pa = wa - back - 0.03 pb = wb - side - 0.05 o = a * pa + b * pb o.z = floor zs = [z for z in _support(g, Matrix.Translation(o) @ rot, shape, False, what) if z is not None] o.z = _rest(zs, False) if zs else floor return Matrix.Translation(o) @ rot, "" return None, "unknown rule %r" % rule def _behind_clear(g, m, d, wall): """The feeder's tunnel reaches 12.3 cm behind the wall's face. Going in from the face, a surface of something else than that wall turned towards the room, within 14 cm, would be the next room's wall, which the tunnel would show through. And no room may lie right behind (a floor at this floor's level 35 cm behind the face).""" for x in (-0.12, 0.0, 0.12): for z in (0.22, 0.34): p = m @ Vector((x, 0.0, z)) + d * 0.002 went = 0.0 while went < 0.14: h = g.ray(p, d, 0.14 - went) if h is None: break if h[2].dot(d) < -0.5 and h[3] != wall: return False, "another wall %.0f cm behind the face (%s)" % ((went + h[0]) * 100.0, h[3]) went += h[0] + 0.002 p = h[1] + d * 0.002 p = m @ Vector((0.0, 0.0, 0.3)) + d * 0.35 floor = m.translation.z - Z_FEEDER f = g.floor(p.x, p.y, p.z, below=1.4) if f is not None and abs(f[0] - floor) < 0.15: return False, "a room behind the wall (floor at %.2f)" % f[0] return True, "" # --------------------------------------------------------------------------------------------------------------------- # checks # --------------------------------------------------------------------------------------------------------------------- class StationTrees(object): """World-space BVHs of the station's meshes near a box, built when first needed.""" def __init__(self, g): self.g = g self.cache = {} def near_items(self, lo, hi): """As near(), with the original object: (object, name, tree).""" items = self.g.items for name, t, i in self._near(lo, hi): yield items[i][1], name, t def near(self, lo, hi): for name, t, i in self._near(lo, hi): yield name, t def _near(self, lo, hi): for i, (key, ob, mw, inv, ilo, ihi, name) in enumerate(self.g.items): if ihi.x < lo.x or ilo.x > hi.x or ihi.y < lo.y or ilo.y > hi.y or ihi.z < lo.z or ilo.z > hi.z: continue t = self.cache.get(i) if t is None: ev, me = self.g.mesh_of(ob) if me is None: self.cache[i] = t = False else: vs = [mw @ v.co for v in me.vertices] ps = [tuple(p.vertices) for p in me.polygons] ev.to_mesh_clear() self.cache[i] = t = BVHTree.FromPolygons(vs, ps, all_triangles=False) if ps else False if t: yield name, t, i def _lift(spec, m): """The model nudged 1 cm off what it rests on or hangs from, so resting is not taken for a clash.""" kind, _, arg = spec["rule"].partition(":") if kind == "wall": return Matrix.Translation(-DIRS[arg] * 0.01 + Vector((0, 0, 0.01))) @ m if kind == "corner": return Matrix.Translation(-(DIRS[arg[0]] + DIRS[arg[1]]) * 0.01 + Vector((0, 0, 0.01))) @ m return Matrix.Translation((0, 0, 0.01)) @ m def _world_box(shape, m): return fg._aabb([m @ c for c in fg.box_corners(shape.lo, shape.hi)]) def check_all(entries, g, report=print): """entries: [{"spec", "matrix"}]. Returns the problems found, as text lines.""" trees = StationTrees(g) problems = [] world = [] for e in entries: spec, m = e["spec"], e["matrix"] shape = shape_of(spec["what"]) kind = spec["rule"].split(":")[0] hung = spec["what"] == "nutrition_feeder" or spec["what"].startswith("set:") lm = _lift(spec, m) tree = shape.world_tree(lm) # the floor's raised trims and panels may stand up to 12 cm into a model's foot (see _rest) tree_floor = tree if hung or kind == "on" else shape.world_tree(Matrix.Translation((0, 0, 0.12)) @ m) lo, hi = _world_box(shape, lm) world.append((e, tree, lo, hi)) hits = sorted({n for n, t in trees.near(lo, hi) if (tree_floor if "floor" in n.lower() else tree).overlap(t)}) if hits: problems.append("%s: cuts into %s" % (spec["id"], ", ".join(hits[:5]))) if kind != "on" and not hung: outside = spec.get("outside", False) or kind == "frame" zs = _support(g, m, shape, outside, spec["what"]) base = m.translation.z near = [z for z in zs if z is not None and abs(z - base) <= (0.08 if outside else 0.12)] deep = [z for z in zs if z is None or base - z > 0.15] if len(near) < 3 or deep: problems.append("%s: not standing on the floor (base %.3f; under it %s)" % ( spec["id"], base, ", ".join("-" if z is None else "%.3f" % z for z in zs))) if not spec["what"].startswith("robot_") and not spec.get("lane_ok"): for lane, (x0, x1, y0, y1) in LANES.items(): if hi.x > x0 and lo.x < x1 and hi.y > y0 and lo.y < y1 and lo.z < 2.0: problems.append("%s: stands in the lane '%s'" % (spec["id"], lane)) for i in range(len(world)): for j in range(i + 1, len(world)): a, ta, alo, ahi = world[i] b, tb, blo, bhi = world[j] if ahi.x < blo.x or alo.x > bhi.x or ahi.y < blo.y or alo.y > bhi.y or ahi.z < blo.z or alo.z > bhi.z: continue ra, rb = a["spec"]["rule"], b["spec"]["rule"] if ra == "on:" + b["spec"]["id"] or rb == "on:" + a["spec"]["id"]: continue if ta.overlap(tb): problems.append("%s and %s: cut into each other" % (a["spec"]["id"], b["spec"]["id"])) return problems # --------------------------------------------------------------------------------------------------------------------- # placing # --------------------------------------------------------------------------------------------------------------------- def _placed_objects(): out = [] for name in GROUPS.values(): col = bpy.data.collections.get(name) if col is not None: out.extend(col.objects) return out def _skip(ob): """What the checks leave out of the station: the furnishing itself, its wall cutters and the models' sources.""" if ob.get("furnish_id") or ob.get("furnish_cut"): return True return any(c.name in GROUPS.values() or c.name == SOURCES for c in ob.users_collection) def _make(spec, m, cols): what = spec["what"] col = cols[spec["group"]] obs = [] if what.startswith("s2:") or what.startswith("set:"): pieces = [(what[3:], 0.0)] if what.startswith("s2:") else SETS[what[4:]] for k, (piece, yaw) in enumerate(pieces): me = season2_mesh(piece) ob = bpy.data.objects.new(spec["id"] if k == 0 else "%s (%s)" % (spec["id"], piece.split("_SM_")[-1]), me) ob.matrix_world = m @ Matrix.Rotation(math.radians(yaw), 4, "Z") col.objects.link(ob) ob["ue_mesh"] = me["ue_mesh"] obs.append(ob) else: src = bpy.data.collections[MODELS[what][1]] ob = bpy.data.objects.new(spec["id"], None) ob.instance_type = "COLLECTION" ob.instance_collection = src ob.empty_display_type = "PLAIN_AXES" ob.empty_display_size = 0.3 ob.matrix_world = m col.objects.link(ob) rel, name, ue = MODELS[what] ob["fab_prop"] = what ob["prop_asset"] = ue if ue != "robot" else "robot:" + what ob["prop_file"] = "source_fab/" + rel ob["fab_title"] = src.get("prop_title", "") ob["prop_source"] = src.get("prop_source", "") if ue == "robot": for k in ("robot_skeletal_mesh", "robot_materials", "robot_anim", "robot_anim_time", "robot_pose"): if k in src: ob[k] = src[k] obs.append(ob) for ob in obs: ob["furnish_id"] = spec["id"] ob["furnish_rule"] = spec["rule"] ob["anchorage_zone"] = spec["room"] ob["anchorage_module"] = "furnishing: %s" % spec["group"] if spec.get("outside") or spec["rule"].startswith("frame:"): ob["anchorage_surroundings"] = True return obs CUTS = "13 FURNISHING | feeder wall cuts (hidden cutters)" CUT_MESH = "FEEDER CUT | tunnel and lip (convex hull)" CUT_MOD = "FEEDER CUT" def _cutter_mesh(): """The feeder's hand tunnel and the lip round its mouth as one closed convex volume, in the feeder's frame (from the lip, 2.4 cm in front of the wall's face, to the tunnel's back, 12.3 cm behind it). Whatever of a wall lies inside it is what showed in the hole (the user, 2026-10-21: "feeders hole in the wall is it possible to mask it so hole shows not the wall inside?").""" import bmesh me = bpy.data.meshes.get(CUT_MESH) if me is not None: return me col = bpy.data.collections[MODELS["nutrition_feeder"][1]] off = Matrix.Translation(-col.instance_offset) bm = bmesh.new() seen = set() for o in col.all_objects: if o.type != "MESH" or not o.name.endswith("SM_NutritionFeeder_Body"): continue m = off @ _world(o) d = o.data for p in d.polygons: mat = d.materials[p.material_index] if p.material_index < len(d.materials) else None if mat is None or not ("NF_Tunnel" in mat.name or "NF_Rim" in mat.name): continue for i in p.vertices: v = m @ d.vertices[i].co k = (round(v.x, 4), round(v.y, 4), round(v.z, 4)) if k not in seen: seen.add(k) bm.verts.new(v) res = bmesh.ops.convex_hull(bm, input=list(bm.verts)) inner = {e for e in res["geom_interior"] + res["geom_unused"] if isinstance(e, bmesh.types.BMVert)} bmesh.ops.delete(bm, geom=list(inner), context="VERTS") me = bpy.data.meshes.new(CUT_MESH) bm.to_mesh(me) bm.free() me.use_fake_user = False return me def uncut_walls(report=print): """Takes the feeders' wall cuts away again: the walls' boolean modifiers and the hidden cutters.""" n = 0 for ob in bpy.data.objects: for md in [m for m in ob.modifiers if m.name.startswith(CUT_MOD)]: ob.modifiers.remove(md) n += 1 if "furnish_cut_by" in ob: del ob["furnish_cut_by"] col = bpy.data.collections.get(CUTS) if col is not None: for ob in list(col.objects): bpy.data.objects.remove(ob) report("[furnish] wall cuts taken away: %d modifiers" % n) return n def cut_walls(report=print): """Behind every feeder, the wall that runs across its tunnel's mouth gets a boolean modifier (difference) with a hidden cutter of the tunnel's shape: the hole then shows the feeder's own tunnel, not the wall. Only those wall objects change; their mesh, which other walls share, does not. Unreal still has the walls whole: the layout lists which instances a feeder cuts ("cut_by_feeders").""" uncut_walls(report=lambda s: None) station = bpy.data.collections[STATION] cuts = get_collection(CUTS, get_collection(COLLECTION, station)) me = _cutter_mesh() hull = [v.co.copy() for v in me.vertices] polys = [tuple(p.vertices) for p in me.polygons] feeders = [o for o in _placed_objects() if o.get("furnish_id", "").startswith("feeder |")] bpy.context.view_layer.update() g = fg.Geo(skip=_skip) trees = StationTrees(g) rows, cutters = [], [] for f in feeders: c = bpy.data.objects.new("%s | %s" % (CUT_MOD, f["furnish_id"]), me) cuts.objects.link(c) # parented to its feeder: slide the feeder along its wall and the hole goes with it (onto another wall: run # cut_walls again) c.parent = f c.matrix_parent_inverse = Matrix.Identity(4) c.matrix_basis = Matrix.Identity(4) c.display_type = "WIRE" c.hide_render = True c["furnish_cut"] = f["furnish_id"] cutters.append(c) wv = [f.matrix_world @ v for v in hull] tree = BVHTree.FromPolygons(wv, polys, all_triangles=False) lo, hi = fg._aabb(wv) hit = [] for ob, name, t in trees.near_items(lo, hi): if not tree.overlap(t): continue if "(in " in name: # a piece inside an instanced collection cannot be cut on its own rows.append("%s: %s is part of an instance, not cut" % (f["furnish_id"], name)) continue md = ob.modifiers.new("%s | %s" % (CUT_MOD, f["furnish_id"]), "BOOLEAN") md.operation = "DIFFERENCE" md.operand_type = "OBJECT" md.object = c md.solver = "EXACT" if hasattr(md, "use_hole_tolerant"): md.use_hole_tolerant = True # the kit's walls are open shells ob["furnish_cut_by"] = sorted(set(list(ob.get("furnish_cut_by", [])) + [f["furnish_id"]])) hit.append(ob.name) rows.append("%s: cuts %s" % (f["furnish_id"], ", ".join(hit) or "nothing")) for c in cutters: c.hide_set(True) # proof: from in front of each feeder, straight into its tunnel (the middle and 6 cm round it, inside its rounded # outline: x -0.016..0.177, z 0.090..0.283), nothing of the station before the tunnel's back bpy.context.view_layer.update() g2 = fg.Geo(skip=_skip) shut = [] for f in feeders: m = f.matrix_world d = (m.to_3x3() @ Vector((0, 1, 0))).normalized() for x, z in ((0.08, 0.19), (0.02, 0.19), (0.14, 0.19), (0.08, 0.13), (0.08, 0.25)): p = m @ Vector((x, -0.15, z)) h = g2.ray(p, d, 0.15 + 0.12) if h is not None: shut.append("%s at (%.2f, %.2f): %s %.0f cm behind the panel's face" % ( f["furnish_id"], x, z, h[3], (h[0] - 0.15) * 100.0)) for r in rows: report("[furnish] %s" % r) report("[furnish] wall cuts: %d feeders, %d walls cut; %s" % ( len(feeders), sum(1 for o in bpy.data.objects if "furnish_cut_by" in o), "every tunnel open to its back" if not shut else "STILL COVERED: " + "; ".join(shut[:6]))) return shut def remove(report=print): uncut_walls(report) n = 0 for name in GROUPS.values(): col = bpy.data.collections.get(name) if col is None: continue for ob in list(col.objects): bpy.data.objects.remove(ob) n += 1 report("[furnish] removed %d placed objects (the models stay in '%s')" % (n, SOURCES)) return n def apply(report=print, only=None): """Places SPEC (or the entries whose id starts with one of `only`) in the open file, in place, then cuts the walls behind the feeders (cut_walls).""" models(report) clean_materials(report) uncut_walls(report=lambda s: None) # the places are found on the walls as they are _SHAPES.clear() station = bpy.data.collections[STATION] top = get_collection(COLLECTION, station) cols = {k: get_collection(v, top) for k, v in GROUPS.items()} for ob in _placed_objects(): if only is None or any(ob.get("furnish_id", "").startswith(o) for o in only): bpy.data.objects.remove(ob) bpy.context.view_layer.update() g = fg.Geo(skip=_skip) placed, failed, notes = {}, [], [] for ob in _placed_objects(): # what stays (a partial apply) is what others may stand on sid = ob.get("furnish_id") for spec in SPEC: if spec["id"] == sid and sid not in placed: placed[sid] = {"spec": spec, "matrix": ob.matrix_world.copy()} for spec in SPEC: if only is not None and not any(spec["id"].startswith(o) for o in only): continue m, why = resolve(spec, g, placed, report) for alt in spec.get("alt", ()): # other places for it, in the order given if m is not None: break m, why2 = resolve(dict(spec, at=alt), g, placed, report) why = why2 if m is None else "at its next place %s" % (alt,) if m is None: failed.append("%s: %s" % (spec["id"], why)) continue if why: notes.append("%s: %s" % (spec["id"], why)) placed[spec["id"]] = {"spec": spec, "matrix": m} _make(spec, m, cols) entries = [placed[s["id"]] for s in SPEC if s["id"] in placed] problems = check_all(entries, g, report) report("[furnish] placed %d of %d (%s)" % (len(placed), len(SPEC), ", ".join( "%s %d" % (k, sum(1 for e in entries if e["spec"]["group"] == k)) for k in GROUPS))) for n in notes: report("[furnish] note: %s" % n) for f in failed: report("[furnish] NOT PLACED %s" % f) for p in problems: report("[furnish] PROBLEM %s" % p) if not failed and not problems: report("[furnish] all checks passed: no clashes with the station or each other, floors under them, walls " "flat behind the wall units, lanes clear") cut_walls(report) return placed, failed, problems def check(report=print): """The placed things as they stand in the file.""" _SHAPES.clear() by_id = {s["id"]: s for s in SPEC} entries, seen = [], set() for ob in _placed_objects(): sid = ob.get("furnish_id") if sid in seen or sid not in by_id: continue seen.add(sid) spec = by_id[sid] m = ob.matrix_world.copy() if spec["what"].startswith("set:"): m = m @ Matrix.Rotation(math.radians(-SETS[spec["what"][4:]][0][1]), 4, "Z") entries.append({"spec": spec, "matrix": m}) g = fg.Geo(skip=_skip) problems = check_all(entries, g, report) report("[furnish] check: %d placed of %d in SPEC; %s" % (len(entries), len(SPEC), "%d problems" % len(problems) if problems else "no problems")) for p in problems: report("[furnish] PROBLEM %s" % p) return problems if __name__ == "__main__": args = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else [] only = None if "--only" in args: only = args[args.index("--only") + 1].split(",") if "--remove" in args: remove() if "--apply" in args: apply(only=only) if "--check" in args: check() if "--save" in args: bpy.ops.file.make_paths_relative() bpy.ops.wm.save_mainfile() print("[furnish] saved %s" % bpy.data.filepath)