def _fill_norm_rotations() -> dict[tuple[tuple[float, float, float], tuple[ float, float, float]], Matrix, ]: """Given a norm->norm rotation, return the angles producing that.""" rotations = {} for norm_ax in 'xyz': for norm_mag in [-1, +1]: norm = Vec.with_axes(norm_ax, norm_mag) for angle_ax in ('pitch', 'yaw', 'roll'): for angle_mag in (-90, 90): angle = Matrix.from_angle( Angle.with_axes(angle_ax, angle_mag)) new_norm = norm @ angle if new_norm != norm: rotations[norm.as_tuple(), new_norm.as_tuple()] = angle # Assign a null rotation as well. rotations[norm.as_tuple(), norm.as_tuple()] = Matrix() rotations[norm.as_tuple(), (-norm).as_tuple()] = Matrix() return rotations
def res_fix_rotation_axis(vmf: VMF, ent: Entity, res: Property): """Properly setup rotating brush entities to match the instance. This uses the orientation of the instance to determine the correct spawnflags to make it rotate in the correct direction. This can either modify an existing entity (which may be in an instance), or generate a new one. The generated brush will be 2x2x2 units large, and always set to be non-solid. For both modes: - `Axis`: specifies the rotation axis local to the instance. - `Reversed`: If set, flips the direction around. - `Classname`: Specifies which entity, since the spawnflags required varies. For application to an existing entity: - `ModifyTarget`: The local name of the entity to modify. For brush generation mode: - `Pos` and `name` are local to the instance, and will set the `origin` and `targetname` respectively. - `Keys` are any other keyvalues to be be set. - `Flags` sets additional spawnflags. Multiple values may be separated by `+`, and will be added together. - `Classname` specifies which entity will be created, as well as which other values will be set to specify the correct orientation. - `AddOut` is used to add outputs to the generated entity. It takes the options `Output`, `Target`, `Input`, `Inst_targ`, `Param` and `Delay`. If `Inst_targ` is defined, it will be used with the input to construct an instance proxy input. If `OnceOnly` is set, the output will be deleted when fired. Permitted entities: * [`func_door_rotating`](https://developer.valvesoftware.com/wiki/func_door_rotating) * [`func_platrot`](https://developer.valvesoftware.com/wiki/func_platrot) * [`func_rot_button`](https://developer.valvesoftware.com/wiki/func_rot_button) * [`func_rotating`](https://developer.valvesoftware.com/wiki/func_rotating) * [`momentary_rot_button`](https://developer.valvesoftware.com/wiki/momentary_rot_button) """ des_axis = res['axis', 'z'].casefold() reverse = res.bool('reversed') door_type = res['classname', 'func_door_rotating'] orient = Matrix.from_angle(Angle.from_str(ent['angles'])) axis = round(Vec.with_axes(des_axis, 1) @ orient, 6) if axis.x > 0 or axis.y > 0 or axis.z > 0: # If it points forward, we need to reverse the rotating door reverse = not reverse axis = abs(axis) try: flag_values = FLAG_ROTATING[door_type] except KeyError: LOGGER.warning('Unknown rotating brush type "{}"!', door_type) return name = res['ModifyTarget', ''] door_ent: Entity | None if name: name = conditions.local_name(ent, name) setter_loc = ent['origin'] door_ent = None spawnflags = 0 else: # Generate a brush. name = conditions.local_name(ent, res['name', '']) pos = res.vec('Pos') @ Angle.from_str(ent['angles', '0 0 0']) pos += Vec.from_str(ent['origin', '0 0 0']) setter_loc = str(pos) door_ent = vmf.create_ent( classname=door_type, targetname=name, origin=pos.join(' '), ) # Extra stuff to apply to the flags (USE, toggle, etc) spawnflags = sum( map( # Add together multiple values srctools.conv_int, res['flags', '0'].split('+') # Make the door always non-solid! )) | flag_values.get('solid_flags', 0) conditions.set_ent_keys(door_ent, ent, res) for output in res.find_all('AddOut'): door_ent.add_out( Output( out=output['Output', 'OnUse'], inp=output['Input', 'Use'], targ=output['Target', ''], inst_in=output['Inst_targ', None], param=output['Param', ''], delay=srctools.conv_float(output['Delay', '']), times=(1 if srctools.conv_bool(output['OnceOnly', False]) else -1), )) # Generate brush door_ent.solids = [vmf.make_prism(pos - 1, pos + 1).solid] # Add or remove flags as needed for flag, value in zip( ('x', 'y', 'z', 'rev'), [axis.x > 1e-6, axis.y > 1e-6, axis.z > 1e-6, reverse], ): if flag not in flag_values: continue if door_ent is not None: if value: spawnflags |= flag_values[flag] else: spawnflags &= ~flag_values[flag] else: # Place a KV setter to set this. vmf.create_ent( 'comp_kv_setter', origin=setter_loc, target=name, mode='flags', kv_name=flag_values[flag], kv_value_global=value, ) if door_ent is not None: door_ent['spawnflags'] = spawnflags # This ent uses a keyvalue for reversing... if door_type == 'momentary_rot_button': vmf.create_ent( 'comp_kv_setter', origin=setter_loc, target=name, mode='kv', kv_name='StartDirection', kv_value_global='1' if reverse else '-1', )
def edit_panel(vmf: VMF, inst: Entity, props: Property, create: bool) -> None: """Implements SetPanelOptions and CreatePanel.""" orient = Matrix.from_angle(Angle.from_str(inst['angles'])) normal: Vec = round(props.vec('normal', 0, 0, 1) @ orient, 6) origin = Vec.from_str(inst['origin']) uaxis, vaxis = Vec.INV_AXIS[normal.axis()] points: set[tuple[float, float, float]] = set() if 'point' in props: for prop in props.find_all('point'): points.add( conditions.resolve_offset(inst, prop.value, zoff=-64).as_tuple()) elif 'pos1' in props and 'pos2' in props: pos1, pos2 = Vec.bbox( conditions.resolve_offset(inst, props['pos1', '-48 -48 0'], zoff=-64), conditions.resolve_offset(inst, props['pos2', '48 48 0'], zoff=-64), ) points.update(map(Vec.as_tuple, Vec.iter_grid(pos1, pos2, 32))) else: # Default to the full tile. points.update({(Vec(u, v, -64.0) @ orient + origin).as_tuple() for u in [-48.0, -16.0, 16.0, 48.0] for v in [-48.0, -16.0, 16.0, 48.0]}) tiles_to_uv: dict[tiling.TileDef, set[tuple[int, int]]] = defaultdict(set) for pos in points: try: tile, u, v = tiling.find_tile(Vec(pos), normal, force=create) except KeyError: continue tiles_to_uv[tile].add((u, v)) if not tiles_to_uv: LOGGER.warning('"{}": No tiles found for panels!', inst['targetname']) return # If bevels is provided, parse out the overall world positions. bevel_world: set[tuple[int, int]] | None try: bevel_prop = props.find_key('bevel') except NoKeyError: bevel_world = None else: bevel_world = set() if bevel_prop.has_children(): # Individually specifying offsets. for bevel_str in bevel_prop.as_array(): bevel_point = Vec.from_str(bevel_str) @ orient + origin bevel_world.add( (int(bevel_point[uaxis]), int(bevel_point[vaxis]))) elif srctools.conv_bool(bevel_prop.value): # Fill the bounding box. bbox_min, bbox_max = Vec.bbox(map(Vec, points)) off = Vec.with_axes(uaxis, 32, vaxis, 32) bbox_min -= off bbox_max += off for pos in Vec.iter_grid(bbox_min, bbox_max, 32): if pos.as_tuple() not in points: bevel_world.add((pos[uaxis], pos[vaxis])) # else: No bevels. panels: list[tiling.Panel] = [] for tile, uvs in tiles_to_uv.items(): if create: panel = tiling.Panel( None, inst, tiling.PanelType.NORMAL, thickness=4, bevels=(), ) panel.points = uvs tile.panels.append(panel) else: for panel in tile.panels: if panel.same_item(inst) and panel.points == uvs: break else: LOGGER.warning('No panel to modify found for "{}"!', inst['targetname']) continue panels.append(panel) pan_type = '<nothing?>' try: pan_type = conditions.resolve_value(inst, props['type']) panel.pan_type = tiling.PanelType(pan_type.lower()) except LookupError: pass except ValueError: raise ValueError('Unknown panel type "{}"!'.format(pan_type)) if 'thickness' in props: panel.thickness = srctools.conv_int( conditions.resolve_value(inst, props['thickness'])) if panel.thickness not in (2, 4, 8): raise ValueError( '"{}": Invalid panel thickess {}!\n' 'Must be 2, 4 or 8.', inst['targetname'], panel.thickness, ) if bevel_world is not None: panel.bevels.clear() for u, v in bevel_world: # Convert from world points to UV positions. u = (u - tile.pos[uaxis] + 48) // 32 v = (v - tile.pos[vaxis] + 48) // 32 # Cull outside here, we wont't use them. if -1 <= u <= 4 and -1 <= v <= 4: panel.bevels.add((u, v)) if 'offset' in props: panel.offset = conditions.resolve_offset(inst, props['offset']) panel.offset -= Vec.from_str(inst['origin']) if 'template' in props: # We only want the template inserted once. So remove it from all but one. if len(panels) == 1: panel.template = inst.fixup.substitute(props['template']) else: panel.template = '' if 'nodraw' in props: panel.nodraw = srctools.conv_bool( inst.fixup.substitute(props['nodraw'], allow_invert=True)) if 'seal' in props: panel.seal = srctools.conv_bool( inst.fixup.substitute(props['seal'], allow_invert=True)) if 'move_bullseye' in props: panel.steals_bullseye = srctools.conv_bool( inst.fixup.substitute(props['move_bullseye'], allow_invert=True)) if 'keys' in props or 'localkeys' in props: # First grab the existing ent, so we can edit it. # These should all have the same value, unless they were independently # edited with mismatching point sets. In that case destroy all those existing ones. existing_ents: set[Entity | None] = {panel.brush_ent for panel in panels} try: [brush_ent] = existing_ents except ValueError: LOGGER.warning( 'Multiple independent panels for "{}" were made, then the ' 'brush entity was edited as a group! Discarding ' 'individual ents...', inst['targetname']) for brush_ent in existing_ents: if brush_ent is not None and brush_ent in vmf.entities: brush_ent.remove() brush_ent = None if brush_ent is None: brush_ent = vmf.create_ent('') old_pos = brush_ent.keys.pop('origin', None) conditions.set_ent_keys(brush_ent, inst, props) if not brush_ent['classname']: if create: # This doesn't make sense, you could just omit the prop. LOGGER.warning( 'No classname provided for panel "{}"!', inst['targetname'], ) # Make it a world brush. brush_ent.remove() brush_ent = None else: # We want to do some post-processing. # Localise any origin value. if 'origin' in brush_ent.keys: pos = Vec.from_str(brush_ent['origin']) pos.localise( Vec.from_str(inst['origin']), Angle.from_str(inst['angles']), ) brush_ent['origin'] = pos elif old_pos is not None: brush_ent['origin'] = old_pos # If it's func_detail, clear out all the keys. # Particularly `origin`, but the others are useless too. if brush_ent['classname'] == 'func_detail': brush_ent.clear_keys() brush_ent['classname'] = 'func_detail' for panel in panels: panel.brush_ent = brush_ent
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 @ template_data.orient # 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 = 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 = round( color_picker.offset @ template_data.orient + template_data.origin + sense_offset, 6) picker_norm = round(color_picker.normal @ template_data.orient, 6) if color_picker.grid_snap: for axis in 'xyz': # Don't realign things in the normal's axis - # those are already fine. if abs(picker_norm[axis]) < 0.01: 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=abs(tiledef.normal.z) > 0.01, bevels=set(), 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 voxel_setter in template.voxel_setters: if not voxel_setter.visgroups.issubset(template_data.visgroups): continue setter_pos = round( voxel_setter.offset @ template_data.orient + template_data.origin + sense_offset, 6) setter_norm = round(voxel_setter.normal @ template_data.orient, 6) norm_axis = setter_norm.axis() u_axis, v_axis = Vec.INV_AXIS[norm_axis] offsets = (-48, -16, 16, 48) for uoff in offsets: for voff in offsets: tiling.edit_quarter_tile( setter_pos + Vec.with_axes(u_axis, uoff, v_axis, voff), setter_norm, voxel_setter.tile_type, silent=True, # Don't log missing positions. force=voxel_setter.force, ) for tile_setter in template.tile_setters: if not tile_setter.visgroups.issubset(template_data.visgroups): continue setter_pos = round( tile_setter.offset @ template_data.orient + template_data.origin + sense_offset, 6) setter_norm = round(tile_setter.normal @ template_data.orient, 6) setter_type: TileType = tile_setter.tile_type 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: picker_res = 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 picker_res is None: raise ValueError( '"{}": Color picker "{}" has no tile to pick!'.format( template.id, tile_setter.picker_name)) setter_type = picker_res 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.get(face.id) or '' 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() 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() elif orig_id in template.vertical_faces: # Rotate the face in increments of 90 degrees, so it is as # upright as possible. pos_u = face.uaxis pos_v = face.vaxis # If both are zero, we're on the floor/ceiling and this is # pointless. if pos_u.z != 0 or pos_v.z != 0: neg_u = UVAxis(-pos_u.x, -pos_u.y, -pos_u.z, pos_u.offset, pos_u.scale) neg_v = UVAxis(-pos_v.x, -pos_v.y, -pos_v.z, pos_v.offset, pos_v.scale) # Each row does u, v = v, -u to rotate 90 degrees. # We want whichever makes V point vertically. face.uaxis, face.vaxis = max([ (pos_u, pos_v), (pos_v, neg_u), (neg_u, neg_v), (neg_v, pos_u), ], key=lambda uv: -uv[1].z) 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 = rand.seed(b'template', norm, face.get_origin()).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() + face.normal(), 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=options.get(float, 'goo_scale') or 0.25, ) face.vaxis = UVAxis( 0, -1, 0, offset=0, scale=options.get(float, 'goo_scale') or 0.25, ) 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: rng = rand.seed(b'temp', template_data.template.id, over_pos, mat) mat = rng.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