예제 #1
0
def test_bbox_parse_block() -> None:
    """Test parsing of a block-shaped bbox from a VMF."""
    vmf = VMF()
    ent = vmf.create_ent(
        'bee2_collision_bbox',
        coll_deco=1,
        coll_physics=1,
        coll_grating=0,
        tags='standard excellent',
    )
    ent.solids.append(vmf.make_prism(Vec(80, 10, 40), Vec(150, 220, 70)).solid)
    ent.solids.append(vmf.make_prism(Vec(-30, 45, 80), Vec(-20, 60, 120)).solid)
    bb2, bb1 =  BBox.from_ent(ent)
    # Allow it to produce in either order.
    if bb1.min_x == -30:
        bb1, bb2 = bb2, bb1
    assert_bbox(
        bb1,
        (80, 10, 40), (150, 220, 70),
        CollideType.DECORATION | CollideType.PHYSICS,
        {'standard', 'excellent'},
    )
    assert_bbox(
        bb2,
        (-30, 45, 80), (-20, 60, 120),
        CollideType.DECORATION | CollideType.PHYSICS,
        {'standard', 'excellent'},
    )
예제 #2
0
def test_bbox_parse_plane(axis: str, mins: tuple3, maxes: tuple3) -> None:
    """Test parsing planar bboxes from a VMF.

    With 5 skip sides, the brush is flattened into the remaining plane.
    """
    vmf = VMF()
    ent = vmf.create_ent('bee2_collision_bbox', coll_solid=1)
    prism = vmf.make_prism(Vec(80, 10, 40), Vec(150, 220, 70), mat='tools/toolsskip')
    getattr(prism, axis).mat = 'tools/toolsclip'
    ent.solids.append(prism.solid)
    [bbox] = BBox.from_ent(ent)
    assert_bbox(bbox, mins, maxes, CollideType.SOLID, set())
예제 #3
0
def test_vmf() -> None:
    """Verify the positions by generating a VMF."""
    vmf = VMF()
    for shape in SHAPES:
        group = vmf.create_visgroup(
            shape.name.replace('/', '_').replace('fizzler',
                                                 '').replace('__', '_'))
        for pos, angle in shape.points:
            ent = vmf.create_ent(
                'prop_static',
                model='models/props_map_editor/fizzler.mdl',
                origin=pos @ angle,
                angles=angle,
            )
            ent.visgroup_ids.add(group.id)
            ent.vis_shown = False
    print('Dumping shape.vmf')
    with open('shape.vmf', 'w') as f:
        vmf.export(f)
예제 #4
0
from srctools import VMF, Entity, Vec
import os

NUMS = [-1, 0, 1]
VECS = [
    Vec(x=1),
    Vec(y=1),
    Vec(z=1),
    Vec(x=-1),
    Vec(y=-1),
    Vec(z=-1),
]

os.makedirs('instances/', exist_ok=True)

inst_vmf = VMF()

inst_vmf.create_ent(
    'func_instance_parms',
    origin='0 0 0',
    parm1='$rot_pitch integer 0',
    parm2='$rot_roll integer 0',
    parm3='$rot_yaw integer 0',
)

for pos in VECS:
    inst_vmf.create_ent('info_target',
                        origin=pos * 128,
                        targetname='offset',
                        angles='0 0 0',
                        rot_pitch='$rot_pitch',
예제 #5
0
    None: 'INVERT',
    'INVERT': None,
}

ExportedTemplate = NamedTuple('ExportedTemplate', [
    ('world', List[Solid]),
    ('detail', Entity),
    ('overlay', List[Entity]),
    ('orig_ids', Dict[str, str]),
    ('template', 'Template'),
    ('origin', Vec),
    ('angles', Vec),
])

# Make_prism() generates faces aligned to world, copy the required UVs.
realign_solid = VMF().make_prism(Vec(-16,-16,-16), Vec(16,16,16)).solid  # type: Solid
REALIGN_UVS = {
    face.normal().as_tuple(): (face.uaxis, face.vaxis)
    for face in realign_solid
}
del realign_solid


class Template:
    """Represents a template before it's imported into a map."""
    def __init__(
        self,
        temp_id,
        world: Dict[str, List[Solid]],
        detail: Dict[str, List[Solid]],
        overlays: Dict[str, List[Entity]],
예제 #6
0
    'death_goo': 'goo',
    'death_turret': 'turret',
    'death_laserfield': 'laserfield',
}


class PossibleQuote(NamedTuple):
    priority: int
    lines: List[Property]


# Create a fake instance to pass to condition flags. This way we can
# reuse all that logic, without breaking flags that check the instance.
fake_inst = VMF().create_ent(
    classname='func_instance',
    file='',
    angles='0 0 0',
    origin='0 0 0',
)


def has_responses():
    """Check if we have any valid 'response' data for Coop."""
    return vbsp.GAME_MODE == 'COOP' and 'CoopResponses' in QUOTE_DATA


def encode_coop_responses(vmf: VMF, pos: Vec, allow_dings: bool,
                          voice_attrs: dict) -> None:
    """Write the coop responses information into the map."""
    config = ConfigFile('bee2/resp_voice.cfg', in_conf_folder=False)
    response_block = QUOTE_DATA.find_key('CoopResponses', [])
예제 #7
0
def combine(
    bsp: BSP,
    bsp_ents: VMF,
    pack: PackList,
    game: Game,
    *,
    studiomdl_loc: Path = None,
    qc_folders: List[Path] = None,
    crowbar_loc: Optional[Path] = None,
    decomp_cache_loc: Path = None,
    auto_range: float = 0,
    min_cluster: int = 2,
    debug_tint: bool = False,
    debug_dump: bool = False,
) -> None:
    """Combine props in this map."""

    # First parse out the bbox ents, so they are always removed.
    bbox_ents = list(bsp_ents.by_class['comp_propcombine_set'])
    for ent in bbox_ents:
        ent.remove()

    if not studiomdl_loc.exists():
        LOGGER.warning('No studioMDL! Cannot propcombine!')
        return

    if not qc_folders and decomp_cache_loc is None:
        # If gameinfo is blah/game/hl2/gameinfo.txt,
        # QCs should be in blah/content/ according to Valve's scheme.
        # But allow users to override this.
        # If Crowbar's path is provided, that means they may want to just supply nothing.
        qc_folders = [game.path.parent.parent / 'content']

    # Parse through all the QC files.
    LOGGER.info('Parsing QC files. Paths: \n{}',
                '\n'.join(map(str, qc_folders)))
    qc_map: Dict[str, Optional[QC]] = {}
    for qc_folder in qc_folders:
        load_qcs(qc_map, qc_folder)
    LOGGER.info('Done! {} props.', len(qc_map))

    map_name = Path(bsp.filename).stem

    # Don't re-parse models continually.
    mdl_map: Dict[str, Optional[Model]] = {}
    # Wipe these, if they're being used again.
    _mesh_cache.clear()
    _coll_cache.clear()
    missing_qcs: Set[str] = set()

    def get_model(filename: str) -> Union[Tuple[QC, Model], Tuple[None, None]]:
        """Given a filename, load/parse the QC and MDL data.

        Either both are returned, or neither are.
        """
        key = unify_mdl(filename)
        try:
            model = mdl_map[key]
        except KeyError:
            try:
                mdl_file = pack.fsys[filename]
            except FileNotFoundError:
                # We don't have this model, we can't combine...
                return None, None
            model = mdl_map[key] = Model(pack.fsys, mdl_file)
            if 'no_propcombine' in model.keyvalues.casefold():
                mdl_map[key] = qc_map[key] = None
                return None, None
        if model is None or key in missing_qcs:
            return None, None

        try:
            qc = qc_map[key]
        except KeyError:
            if crowbar_loc is None:
                missing_qcs.add(key)
                return None, None
            qc = decompile_model(pack.fsys, decomp_cache_loc, crowbar_loc,
                                 filename, model.checksum)
            qc_map[key] = qc

        if qc is None:
            return None, None
        else:
            return qc, model

    # Ignore these two, they don't affect our new prop.
    relevant_flags = ~(StaticPropFlags.HAS_LIGHTING_ORIGIN
                       | StaticPropFlags.DOES_FADE)

    def get_grouping_key(prop: StaticProp) -> Optional[tuple]:
        """Compute a grouping key for this prop.

        Only props with matching key can be possibly combined.
        If None it cannot be combined.
        """
        qc, model = get_model(prop.model)

        if model is None or qc is None:
            return None

        return (
            # Must be first, we pull this out later.
            frozenset({
                tex.casefold().replace('\\', '/')
                for tex in model.iter_textures([prop.skin])
            }),
            model.flags.value,
            (prop.flags & relevant_flags).value,
            model.contents,
            model.surfaceprop,
            prop.renderfx,
            *prop.tint,
        )

    prop_count = 0

    # First, construct groups of props that can possibly be combined.
    prop_groups = defaultdict(
        list)  # type: Dict[Optional[tuple], List[StaticProp]]

    # This holds the list of all props we want in the map -
    # combined ones, and any we reject for whatever reason.
    final_props: List[StaticProp] = []
    rejected: List[StaticProp] = []

    if bbox_ents:
        LOGGER.info('Propcombine sets present ({}), combining...',
                    len(bbox_ents))
        grouper = group_props_ent(
            prop_groups,
            rejected,
            get_model,
            bbox_ents,
            min_cluster,
        )
    elif auto_range > 0:
        LOGGER.info('Automatically finding propcombine sets...')
        grouper = group_props_auto(
            prop_groups,
            rejected,
            auto_range,
            min_cluster,
        )
    else:
        # No way provided to choose props.
        LOGGER.info('No propcombine groups provided.')
        return

    for prop in bsp.static_props():
        prop_groups[get_grouping_key(prop)].append(prop)
        prop_count += 1

    # These are models we cannot merge no matter what -
    # no source files etc.
    cannot_merge = prop_groups.pop(None, [])
    final_props.extend(cannot_merge)

    LOGGER.debug(
        'Prop groups: \n{}', '\n'.join([
            f'{group}: {len(props)}'
            for group, props in sorted(prop_groups.items(),
                                       key=operator.itemgetter(0))
        ]))

    group_count = 0
    with ModelCompiler(
            game,
            studiomdl_loc,
            pack,
            map_name,
            'propcombine',
    ) as compiler:
        for group in grouper:
            grouped_prop = combine_group(compiler, group, get_model)
            if debug_tint:
                # Compute a random hue, and convert back to RGB 0-255.
                r, g, b = colorsys.hsv_to_rgb(random.random(), 1, 1)
                grouped_prop.tint = Vec(round(r * 255), round(g * 255),
                                        round(b * 255))
            final_props.append(grouped_prop)
            group_count += 1

    final_props.extend(rejected)

    if debug_dump:
        dump_vmf = VMF()
        for prop in rejected:
            dump_vmf.create_ent(
                'prop_static',
                model=prop.model,
                origin=prop.origin,
                angles=prop.angles,
                solid=prop.solidity,
                rendercolor=prop.tint,
            )
        dump_fname = Path(bsp.filename).with_name(map_name +
                                                  '_propcombine_reject.vmf')
        LOGGER.info('Dumping uncombined props to {}...', dump_fname)
        with dump_fname.open('w') as f:
            dump_vmf.export(f)

    LOGGER.info(
        'Combined {} props into {}:\n - {} grouped models\n - {} ineligable\n - {} had no group',
        prop_count,
        len(final_props),
        group_count,
        len(cannot_merge),
        len(rejected),
    )
    LOGGER.debug('Models with unknown QCs: \n{}',
                 '\n'.join(sorted(missing_qcs)))
    # If present, delete old cache file. We'll have cleaned up the models.
    try:
        os.remove(compiler.model_folder_abs / 'cache.vdf')
    except FileNotFoundError:
        pass

    bsp.write_static_props(final_props)
예제 #8
0
def retexture_template(
    template_data: ExportedTemplate,
    origin: Vec,
    fixup: EntityFixup = None,
    replace_tex: Mapping[str, Union[List[str], str]] = srctools.EmptyMapping,
    force_colour: Portalable = None,
    force_grid: TileSize = None,
    generator: GenCat = GenCat.NORMAL,
    sense_offset: Optional[Vec] = None,
):
    """Retexture a template at the given location.

    - Only textures in the TEMPLATE_RETEXTURE dict will be replaced.
    - Others will be ignored (nodraw, plasticwall, etc)
    - Wall textures pointing up and down will switch to floor/ceiling textures.
    - Textures of the same type, normal and inst origin will randomise to the
      same type.
    - replace_tex is a replacement table. This overrides everything else.
      The values should either be a list (random), or a single value.
    - If force_colour is set, all tile textures will be switched accordingly.
      If set to 'INVERT', white and black textures will be swapped.
    - If force_grid is set, all tile textures will be that size.
    - generator defines the generator category to use for surfaces.
    - Fixup is the inst.fixup value, used to allow $replace in replace_tex.
    - If sense_offset is set, color pickers and tilesetters will be treated
      as if they were locally offset this far in the template.
    """

    template = template_data.template

    rev_id_mapping = {
        new_id: str(old_id)
        for old_id, new_id in template_data.orig_ids.items()
    }

    all_brushes = list(template_data.world)
    if template_data.detail is not None:
        all_brushes.extend(template_data.detail.solids)

    # Template faces are randomised per block and side. This means
    # multiple templates in the same block get the same texture, so they
    # can clip into each other without looking bad.
    rand_prefix = 'TEMPLATE_{0.x}_{0.y}_{0.z}:'.format(origin // 128)

    # Reprocess the replace_tex passed in, converting values.
    evalled_replace_tex: Dict[str, List[str]] = {}
    for key, value in replace_tex.items():
        if isinstance(value, str):
            value = [value]
        if fixup is not None:
            # Convert the material and key for fixup names.
            value = [
                fixup[mat] if mat.startswith('$') else mat for mat in value
            ]
            if key.startswith('$'):
                key = fixup[key]
        # If starting with '#', it's a face id, or a list of those.
        if key.startswith('#'):
            for k in key[1:].split():
                try:
                    old_id = int(k)
                except (ValueError, TypeError):
                    pass
                else:
                    evalled_replace_tex.setdefault('#' + str(old_id),
                                                   []).extend(value)
        else:
            evalled_replace_tex.setdefault(key.casefold(), []).extend(value)

    if sense_offset is None:
        sense_offset = Vec()
    else:
        sense_offset = sense_offset.copy().rotate(*template_data.angles)

    # For each face, if it needs to be forced to a colour, or None if not.
    # If a string it's forced to that string specifically.
    force_colour_face: Dict[str, Union[Portalable, str,
                                       None]] = defaultdict(lambda: None)
    # Picker names to their results.
    picker_results: Dict[str,
                         Optional[Portalable]] = template_data.picker_results
    picker_type_results: Dict[str, Optional[TileType]] = {}

    # If the "use patterns" option is enabled, face ID -> temp face to copy from.
    picker_patterned: Dict[str, Optional[Side]] = defaultdict(lambda: None)
    # Then also a cache of the tiledef -> dict of template faces.
    pattern_cache: Dict[tiling.TileDef, Dict[Tuple[int, int], Side]] = {}

    # Already sorted by priority.
    for color_picker in template.color_pickers:
        if not color_picker.visgroups.issubset(template_data.visgroups):
            continue

        picker_pos = color_picker.offset.copy().rotate(*template_data.angles)
        picker_pos += template_data.origin + sense_offset
        picker_norm = Vec(color_picker.normal).rotate(*template_data.angles)

        if color_picker.grid_snap:
            for axis in 'xyz':
                # Don't realign things in the normal's axis -
                # those are already fine.
                if not picker_norm[axis]:
                    picker_pos[axis] = picker_pos[axis] // 128 * 128 + 64

        try:
            tiledef, u, v = tiling.find_tile(picker_pos, picker_norm)
        except KeyError:
            # Doesn't exist. But only set if not already present.
            if color_picker.name:
                picker_results.setdefault(color_picker.name, None)
                picker_type_results.setdefault(color_picker.name, None)
            continue

        tile_type = tiledef[u, v]

        picker_type_results[color_picker.name] = tile_type

        try:
            tile_color = tile_type.color
        except ValueError:
            # Not a tile with color (void, etc). Treat as missing a color.
            picker_results.setdefault(color_picker.name, None)
            continue

        if color_picker.name and picker_results.get(color_picker.name,
                                                    None) is None:
            picker_results[color_picker.name] = tile_color

        if color_picker.use_pattern:
            # Generate the brushwork for the tile to determine the top faces
            # required. We can then throw away the brushes themselves.
            try:
                pattern = pattern_cache[tiledef]
            except KeyError:
                pattern = pattern_cache[tiledef] = {}
                tiledef.gen_multitile_pattern(
                    VMF(),
                    {(u, v): tiledef[u, v]
                     for u in (0, 1, 2, 3) for v in (0, 1, 2, 3)},
                    is_wall=tiledef.normal.z != 0,
                    bevels=(False, False, False, False),
                    normal=tiledef.normal,
                    face_output=pattern,
                )

            for side in color_picker.sides:
                if picker_patterned[side] is None and (u, v) in pattern:
                    picker_patterned[side] = pattern[u, v]
        else:
            # Only do the highest priority successful one.
            for side in color_picker.sides:
                if force_colour_face[side] is None:
                    if tile_color is tile_color.WHITE:
                        force_colour_face[
                            side] = color_picker.force_tex_white or tile_color
                    else:
                        force_colour_face[
                            side] = color_picker.force_tex_black or tile_color

        if color_picker.after is AfterPickMode.VOID:
            tiledef[u, v] = TileType.VOID
        elif color_picker.after is AfterPickMode.NODRAW:
            tiledef[u, v] = TileType.NODRAW

    for tile_setter in template.tile_setters:
        if not tile_setter.visgroups.issubset(template_data.visgroups):
            continue

        setter_pos = Vec(tile_setter.offset).rotate(*template_data.angles)
        setter_pos += template_data.origin + sense_offset
        setter_norm = Vec(tile_setter.normal).rotate(*template_data.angles)
        setter_type = tile_setter.tile_type  # type: TileType

        if tile_setter.color == 'copy':
            if not tile_setter.picker_name:
                raise ValueError('"{}": Tile Setter set to copy mode '
                                 'must have a color picker!'.format(
                                     template.id))
            # If a color picker is set, it overrides everything else.
            try:
                setter_type = picker_type_results[tile_setter.picker_name]
            except KeyError:
                raise ValueError('"{}": Tile Setter specified color picker '
                                 '"{}" which does not exist!'.format(
                                     template.id, tile_setter.picker_name))
            if setter_type is None:
                raise ValueError(
                    '"{}": Color picker "{}" has no tile to pick!'.format(
                        template.id, tile_setter.picker_name))
        elif setter_type.is_tile:
            if tile_setter.picker_name:
                # If a color picker is set, it overrides everything else.
                try:
                    setter_color = picker_results[tile_setter.picker_name]
                except KeyError:
                    raise ValueError(
                        '"{}": Tile Setter specified color picker '
                        '"{}" which does not exist!'.format(
                            template.id, tile_setter.picker_name))
                if setter_color is None:
                    raise ValueError(
                        '"{}": Color picker "{}" has no tile to pick!'.format(
                            template.id, tile_setter.picker_name))
            elif isinstance(tile_setter.color, Portalable):
                # The color was specifically set.
                setter_color = tile_setter.color
            elif isinstance(force_colour, Portalable):
                # Otherwise it copies the forced colour -
                # it needs to be white or black.
                setter_color = force_colour
            else:
                # We need a forced color, but none was provided.
                raise ValueError(
                    '"{}": Tile Setter set to use colour value from the '
                    "template's overall color, "
                    'but not given one!'.format(template.id))

            # Inverting applies to all of these.
            if force_colour == 'INVERT':
                setter_color = ~setter_color

            setter_type = TileType.with_color_and_size(
                setter_type.tile_size,
                setter_color,
            )

        tiling.edit_quarter_tile(
            setter_pos,
            setter_norm,
            setter_type,
            silent=True,  # Don't log missing positions.
            force=tile_setter.force,
        )

    for brush in all_brushes:
        for face in brush:
            orig_id = rev_id_mapping[face.id]

            if orig_id in template.skip_faces:
                continue

            template_face = picker_patterned[orig_id]
            if template_face is not None:
                face.mat = template_face.mat
                face.uaxis = template_face.uaxis.copy()
                face.vaxis = template_face.vaxis.copy()
                continue

            folded_mat = face.mat.casefold()

            norm = face.normal()
            random.seed(rand_prefix + norm.join('_'))

            if orig_id in template.realign_faces:
                try:
                    uaxis, vaxis = REALIGN_UVS[norm.as_tuple()]
                except KeyError:
                    LOGGER.warning(
                        'Realign face in template "{}" ({} in final) is '
                        'not on grid!',
                        template.id,
                        face.id,
                    )
                else:
                    face.uaxis = uaxis.copy()
                    face.vaxis = vaxis.copy()

            override_mat: Optional[List[str]]
            try:
                override_mat = evalled_replace_tex['#' + orig_id]
            except KeyError:
                try:
                    override_mat = evalled_replace_tex[folded_mat]
                except KeyError:
                    override_mat = None

            if override_mat is not None:
                # Replace_tex overrides everything.
                mat = random.choice(override_mat)
                if mat[:1] == '$' and fixup is not None:
                    mat = fixup[mat]
                if mat.startswith('<') and mat.endswith('>'):
                    # Lookup in the style data.
                    gen, mat = texturing.parse_name(mat[1:-1])
                    mat = gen.get(face.get_origin(), mat)
                # If blank, don't set.
                if mat:
                    face.mat = mat
                    continue

            if folded_mat == 'tile/white_wall_tile003b':
                LOGGER.warning(
                    '"{}": white_wall_tile003b has changed definitions.',
                    template.id)

            try:
                tex_type = TEMPLATE_RETEXTURE[folded_mat]
            except KeyError:
                continue  # It's nodraw, or something we shouldn't change

            tex_colour: Optional[Portalable]
            gen_type, tex_name, tex_colour = tex_type

            if not gen_type.is_tile:
                # It's something like squarebeams or backpanels, so
                # we don't need much special handling.
                texturing.apply(gen_type, face, tex_name)

                if tex_name in ('goo', 'goo_cheap'):
                    if norm != (0, 0, 1):
                        # Goo must be facing upright!
                        # Retexture to nodraw, so a template can be made with
                        # all faces goo to work in multiple orientations.
                        face.mat = 'tools/toolsnodraw'
                    else:
                        # Goo always has the same orientation!
                        face.uaxis = UVAxis(
                            1,
                            0,
                            0,
                            offset=0,
                            scale=vbsp_options.get(float, 'goo_scale'),
                        )
                        face.vaxis = UVAxis(
                            0,
                            -1,
                            0,
                            offset=0,
                            scale=vbsp_options.get(float, 'goo_scale'),
                        )
                continue
            else:
                assert isinstance(tex_colour, Portalable)
                # Allow overriding to panel or bullseye types.
                if gen_type is GenCat.NORMAL:
                    gen_type = generator
            # Otherwise, it's a panel wall or the like

            if force_colour_face[orig_id] is not None:
                tex_colour = force_colour_face[orig_id]
                if isinstance(tex_colour, str):
                    face.mat = tex_colour
                    continue
            elif force_colour == 'INVERT':
                # Invert the texture
                tex_colour = ~tex_colour
            elif force_colour is not None:
                tex_colour = force_colour

            if force_grid is not None:
                tex_name = force_grid

            texturing.apply(gen_type, face, tex_name, tex_colour)

    for over in template_data.overlay[:]:
        over_pos = Vec.from_str(over['basisorigin'])
        mat = over['material'].casefold()

        if mat in replace_tex:
            mat = random.choice(replace_tex[mat])
            if mat[:1] == '$' and fixup is not None:
                mat = fixup[mat]
            if mat.startswith('<') or mat.endswith('>'):
                mat = mat[1:-1]
                gen, tex_name = texturing.parse_name(mat[1:-1])
                mat = gen.get(over_pos, tex_name)
        else:
            try:
                sign_type = consts.Signage(mat)
            except ValueError:
                pass
            else:
                mat = texturing.OVERLAYS.get(over_pos, sign_type)

        if mat == '':
            # If blank, remove the overlay from the map and the list.
            # (Since it's inplace, this can affect the tuple.)
            template_data.overlay.remove(over)
            over.remove()
        else:
            over['material'] = mat
예제 #9
0
import srctools.logger
from packages import (
    PakObject,
    ParseData,
    get_config,
    ExportData,
)
from srctools import VMF, Vec, Solid

LOGGER = srctools.logger.get_logger(__name__)

# Don't change face IDs when copying to here.
# This allows users to refer to the stuff in templates specifically.
# The combined VMF isn't to be compiled or edited outside of us, so it's fine
# to have overlapping IDs between templates.
TEMPLATE_FILE = VMF(preserve_ids=True)


class BrushTemplate(PakObject, has_img=False, allow_mult=True):
    """A template brush which will be copied into the map, then retextured.

    This allows the sides of the brush to swap between wall/floor textures
    based on orientation.
    All world and detail brushes from the given VMF will be copied.
    """
    # For scaling templates, maps normals to the prefix to use in the ent.
    NORMAL_TO_NAME = {
        (0, 0, 1): 'up',
        (0, 0, -1): 'dn',
        (0, 1, 0): 'n',
        (0, -1, 0): 's',