Hi everyone,
I am developing a custom stair generator script using pyRevit in Revit 2023. The script generates stairs using a combination of Floors (for treads/slopes) and Walls (for risers and side profiles).
I’ve encountered a very strange issue: The side profile wall creation works perfectly when executed on Level 2 or higher, but it completely fails (or rolls back) when executed on Level 1 (or the lowest level of the project).
Here is the specific function I use to create the side profile wall:
# -*- coding: utf-8 -*-
import clr
import math
import os
import json
import codecs
import tempfile
# UI 라이브러리 참조
clr.AddReference('System.Windows.Forms')
from Autodesk.Revit.DB import *
from Autodesk.Revit.UI.Selection import ObjectSnapTypes
from pyrevit import revit, DB, script, forms
from System.Collections.Generic import List
doc = revit.doc
uidoc = revit.uidoc
# =======================================================
# 0. 설정 저장/로드
# =======================================================
CONFIG_FILE = os.path.join(tempfile.gettempdir(), "pyrevit_stair_config.json")
def load_config():
default_config = {
"height": "3000",
"count": "15",
"width": "1200",
"depth": "280",
"thick": "30",
"offset": "0",
"underside_drop": "180",
"run_offset_base": "50",
"finish_type": u"면손보기" # 추가
}
if os.path.exists(CONFIG_FILE):
try:
with open(CONFIG_FILE, "r") as f:
saved_config = json.load(f)
default_config.update(saved_config)
except:
pass
return default_config
def save_config(data):
try:
with open(CONFIG_FILE, "w") as f:
json.dump(data, f)
except:
pass
# =======================================================
# 1. UI 설정 (XAML)
# =======================================================
xaml_string = u"""
<Window xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="계단 생성기 (PyRevit)" Height="470" Width="380" WindowStartupLocation="CenterScreen" ResizeMode="NoResize">
<StackPanel Margin="20">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="180"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="30"/>
<RowDefinition Height="30"/>
<RowDefinition Height="30"/>
<RowDefinition Height="30"/>
<RowDefinition Height="30"/>
<RowDefinition Height="30"/>
<RowDefinition Height="30"/>
<RowDefinition Height="30"/>
<RowDefinition Height="30"/>
<RowDefinition Height="45"/>
</Grid.RowDefinitions>
<Label Grid.Row="0" Content="1. 총 높이 (mm):"/>
<TextBox Name="tb_height" Grid.Row="0" Grid.Column="1" VerticalAlignment="Center"/>
<Label Grid.Row="1" Content="2. 계단 단 수 (개):"/>
<TextBox Name="tb_count" Grid.Row="1" Grid.Column="1" VerticalAlignment="Center"/>
<Label Grid.Row="2" Content="3. 계단 폭 (mm):"/>
<TextBox Name="tb_width" Grid.Row="2" Grid.Column="1" VerticalAlignment="Center"/>
<Label Grid.Row="3" Content="4. 디딤판 깊이 (mm):"/>
<TextBox Name="tb_depth" Grid.Row="3" Grid.Column="1" VerticalAlignment="Center"/>
<Label Grid.Row="4" Content="5. 몰탈 두께 (T):"/>
<TextBox Name="tb_thick" Grid.Row="4" Grid.Column="1" VerticalAlignment="Center"/>
<Label Grid.Row="5" Content="6. 시작 높이 (mm):"/>
<TextBox Name="tb_offset" Grid.Row="5" Grid.Column="1" VerticalAlignment="Center"
ToolTip="참(Landing) 높이만큼 띄워서 시작할 때 입력"/>
<Label Grid.Row="6" Content="7. 계단참 슬래브 두께 (mm):"/>
<TextBox Name="tb_underside_drop" Grid.Row="6" Grid.Column="1" VerticalAlignment="Center"
ToolTip="계단 하부선보다 아래로 내릴 값 / 기본 180"/>
<Label Grid.Row="7" Content="8. 진행방향 오프셋 (mm):"/>
<TextBox Name="tb_run_offset_base" Grid.Row="7" Grid.Column="1" VerticalAlignment="Center"
ToolTip="최종 Run Offset = 입력값 + 몰탈두께 / 기본 50"/>
<Label Grid.Row="8" Content="9. 하부 마감 타입:"/>
<ComboBox Name="cb_finish_type" Grid.Row="8" Grid.Column="1" VerticalAlignment="Center">
<ComboBoxItem Content="면손보기"/>
<ComboBoxItem Content="수성페인트"/>
</ComboBox>
<Button Name="btn_ok" Grid.Row="9" Grid.Column="0" Grid.ColumnSpan="2"
Content="위치 지정 및 생성" Margin="0,12,0,0"/>
</Grid>
</StackPanel>
</Window>
"""
xaml_path = os.path.join(tempfile.gettempdir(), "pyrevit_stair_ui.xaml")
with codecs.open(xaml_path, "w", "utf-8") as f:
f.write(xaml_string)
class StairWindow(forms.WPFWindow):
def __init__(self):
forms.WPFWindow.__init__(self, xaml_path)
config = load_config()
self.tb_height.Text = config["height"]
self.tb_count.Text = config["count"]
self.tb_width.Text = config["width"]
self.tb_depth.Text = config["depth"]
self.tb_thick.Text = config["thick"]
self.tb_offset.Text = config["offset"]
self.tb_underside_drop.Text = config["underside_drop"]
self.tb_run_offset_base.Text = config["run_offset_base"]
saved_finish = config.get("finish_type", u"면손보기")
for item in self.cb_finish_type.Items:
try:
if unicode(item.Content) == saved_finish:
self.cb_finish_type.SelectedItem = item
break
except:
pass
if self.cb_finish_type.SelectedIndex < 0:
self.cb_finish_type.SelectedIndex = 0
self.btn_ok.Click += self.btn_ok_Click
def btn_ok_Click(self, sender, e):
selected_finish = u"면손보기"
if self.cb_finish_type.SelectedItem:
try:
selected_finish = unicode(self.cb_finish_type.SelectedItem.Content)
except:
pass
current_data = {
"height": self.tb_height.Text,
"count": self.tb_count.Text,
"width": self.tb_width.Text,
"depth": self.tb_depth.Text,
"thick": self.tb_thick.Text,
"offset": self.tb_offset.Text,
"underside_drop": self.tb_underside_drop.Text,
"run_offset_base": self.tb_run_offset_base.Text,
"finish_type": selected_finish
}
save_config(current_data)
self.Close()
# =======================================================
# 2. 실행 및 데이터 수집
# =======================================================
try:
window = StairWindow()
window.ShowDialog()
except Exception as e:
forms.alert(u"UI 로드 중 에러 발생:\n" + str(e))
script.exit()
try:
if not window.tb_height.Text:
script.exit()
def mm_to_feet(mm):
return mm * 0.00328084
total_height_mm = float(window.tb_height.Text)
step_count = int(window.tb_count.Text)
stair_width_mm = float(window.tb_width.Text)
tread_depth_mm = float(window.tb_depth.Text)
mortar_thick_mm = float(window.tb_thick.Text)
start_offset_mm = float(window.tb_offset.Text) if window.tb_offset.Text else 0.0
# 사용자 입력값
underside_drop_mm = float(window.tb_underside_drop.Text) if window.tb_underside_drop.Text else 180.0
run_offset_base_mm = float(window.tb_run_offset_base.Text) if window.tb_run_offset_base.Text else 50.0
finish_type = u"면손보기"
if window.cb_finish_type.SelectedItem:
try:
finish_type = unicode(window.cb_finish_type.SelectedItem.Content)
except:
pass
# 최종 진행방향 오프셋 = 기본 오프셋 + 몰탈 두께
underside_run_offset_mm = run_offset_base_mm + mortar_thick_mm
mortar_t = mm_to_feet(mortar_thick_mm)
half_mortar = mortar_t * 0.5
start_offset_feet = mm_to_feet(start_offset_mm)
underside_drop = mm_to_feet(underside_drop_mm)
underside_run_offset = mm_to_feet(underside_run_offset_mm)
except ValueError:
forms.alert(u"숫자 형식이 잘못되었습니다.")
script.exit()
# =======================================================
# 3. 타입 찾기
# =======================================================
target_type_name = u"시멘트몰탈_{}T#계단실".format(window.tb_thick.Text)
def get_type_by_name(cls, name):
collector = FilteredElementCollector(doc).OfClass(cls).WhereElementIsElementType()
for elem in collector:
try:
p = elem.get_Parameter(BuiltInParameter.SYMBOL_NAME_PARAM)
elem_name = p.AsString() if p else None
if not elem_name:
elem_name = getattr(elem, "Name", None)
if elem_name == name:
return elem
except:
continue
return None
target_wall_type = get_type_by_name(WallType, target_type_name)
target_floor_type = get_type_by_name(FloorType, target_type_name)
if not target_wall_type or not target_floor_type:
forms.alert(u"타입을 찾을 수 없습니다: " + target_type_name)
script.exit()
tile_floor_type = get_type_by_name(FloorType, u"자기질타일_300X300_계단실#10T_디딤판")
tile_wall_type = get_type_by_name(WallType, u"자기질타일_300X300_계단실#10T")
if finish_type == u"수성페인트":
underside_floor_type_name = u"수성페인트#1T"
underside_wall_type_name = u"수성페인트_골조#1T"
else:
underside_floor_type_name = u"면손보기(노출면)#1T"
underside_wall_type_name = u"면손보기(노출면)#1T"
underside_floor_type = get_type_by_name(FloorType, underside_floor_type_name)
underside_wall_type = get_type_by_name(WallType, underside_wall_type_name)
if not tile_floor_type or not tile_wall_type:
forms.alert(u"타일 타입을 찾을 수 없습니다.")
script.exit()
if not underside_floor_type:
forms.alert(u'밑면 경사판용 FLOOR 타입을 찾을 수 없습니다: "{}"'.format(underside_floor_type_name))
script.exit()
if not underside_wall_type:
forms.alert(u'밑면 측면 마감용 WALL 타입을 찾을 수 없습니다: "{}"'.format(underside_wall_type_name))
script.exit()
tile_t = mm_to_feet(10.0)
half_tile = tile_t * 0.5
# =======================================================
# 4. 위치 지정
# =======================================================
try:
snap_opts = ObjectSnapTypes.Endpoints | ObjectSnapTypes.Intersections | ObjectSnapTypes.Nearest
start_pt = uidoc.Selection.PickPoint(snap_opts, u"계단의 [시작점]을 클릭하세요.")
end_pt = uidoc.Selection.PickPoint(snap_opts, u"계단이 진행될 [방향]을 클릭하세요.")
except Exception:
script.exit()
# =======================================================
# 5. Transform 계산
# =======================================================
def get_transform(start_p, end_p):
vec = end_p - start_p
vec_flat = XYZ(vec.X, vec.Y, 0)
if vec_flat.IsZeroLength():
return Transform.Identity
y_axis = XYZ.BasisY
angle = y_axis.AngleTo(vec_flat)
if y_axis.CrossProduct(vec_flat).Z < 0:
angle = -angle
return Transform.CreateTranslation(start_p).Multiply(
Transform.CreateRotationAtPoint(XYZ.BasisZ, angle, XYZ.Zero)
)
final_transform = get_transform(start_pt, end_pt)
# =======================================================
# 5-1. 밑면 경사판(Floor 1개) 생성
# =======================================================
def create_underside_slope_floor(doc, level, level_id, floor_type, final_transform,
width, total_run, start_z, total_h, run_offset):
"""
밑면 경사판을 Floor 1개로 생성
- 타입: 면손보기(노출면)#1T
- 경사 방향: 계단 진행방향(Y)
- 시작 높이: start_z
- 끝 높이: start_z + total_h
- 진행방향 오프셋: run_offset
"""
if total_run <= 0:
raise Exception("total_run 값이 0 이하입니다.")
slope = float(total_h) / float(total_run)
# 계단 진행방향(Y) 오프셋 적용
y0 = run_offset
y1 = total_run + run_offset
pts = [
final_transform.OfPoint(XYZ(0, y0, 0)),
final_transform.OfPoint(XYZ(width, y0, 0)),
final_transform.OfPoint(XYZ(width, y1, 0)),
final_transform.OfPoint(XYZ(0, y1, 0))
]
arrow_start = final_transform.OfPoint(XYZ(width * 0.5, y0, 0))
arrow_end = final_transform.OfPoint(XYZ(width * 0.5, y1, 0))
slope_arrow = Line.CreateBound(arrow_start, arrow_end)
new_floor = None
# 방법 1: Floor.Create 오버로드 시도
try:
profile = CurveLoop()
for k in range(4):
profile.Append(Line.CreateBound(pts[k], pts[(k + 1) % 4]))
new_floor = Floor.Create(
doc,
[profile],
floor_type.Id,
level_id,
False,
slope_arrow,
slope
)
# 방법 2: 구버전 fallback
except Exception:
try:
ca = CurveArray()
for k in range(4):
ca.Append(Line.CreateBound(pts[k], pts[(k + 1) % 4]))
new_floor = doc.Create.NewSlab(ca, level, slope_arrow, slope, False)
if new_floor.GetTypeId() != floor_type.Id:
new_floor.ChangeTypeId(floor_type.Id)
except Exception as e:
raise Exception(u"밑면 경사판(Floor) 생성 실패: {}".format(e))
p_offset = new_floor.get_Parameter(BuiltInParameter.FLOOR_HEIGHTABOVELEVEL_PARAM)
if p_offset and (not p_offset.IsReadOnly):
p_offset.Set(start_z)
return new_floor
from System.Collections.Generic import List
def create_left_side_profile_wall(doc, level_id, wall_type, final_transform,
run_offset, step_count, tread, riser_h,
stair_start_z, underside_start_z,
total_h, mortar_t, tile_t, underside_drop):
wall_t = wall_type.Width
total_run = step_count * tread
gap_mm = 0.5
gap_feet = gap_mm *0.00328084
# 진행방향(+Y) 기준 왼쪽
x_loc = -wall_t * 0.5 - gap_feet
y0 = run_offset
y1 = run_offset + total_run
# 수정 1: y00 계산
y00 = y0 - run_offset + mortar_t
final_base_z = underside_start_z
pts_local = []
# 하부 시작/끝 (밑면 경사선)
pts_local.append(XYZ(x_loc, y0, 0.0))
pts_local.append(XYZ(x_loc, y1, total_h))
# 수정 2: top_drop 대신 - tile_t 적용
# 상부 끝점
top_last_abs_z = stair_start_z + total_h + mortar_t - mortar_t + tile_t - tile_t
top_last_rel_z = top_last_abs_z - final_base_z
pts_local.append(XYZ(x_loc, y1, top_last_rel_z))
# 계단 단차 형상
for i in reversed(range(step_count)):
# 수정 3: step_y0 계산 수정
step_y0 = y00 + i * tread
step_top_abs_z = stair_start_z + (i + 1) * riser_h + mortar_t - mortar_t + tile_t - tile_t
step_top_rel_z = step_top_abs_z - final_base_z
pts_local.append(XYZ(x_loc, step_y0, step_top_rel_z))
if i > 0:
prev_step_abs_z = stair_start_z + i * riser_h + mortar_t - mortar_t + tile_t - tile_t
prev_step_rel_z = prev_step_abs_z - final_base_z
pts_local.append(XYZ(x_loc, step_y0, prev_step_rel_z))
# 수정 1: 꼭지점 하나를 추가하여 닫힌 프로파일(Closed Loop) 완성
pts_local.append(XYZ(x_loc, y00, 0.0))
# world 좌표 변환
pts_world = [final_transform.OfPoint(p) for p in pts_local]
# 닫힌 프로파일 작성
profile = List[Curve]()
for i in range(len(pts_world)):
p1 = pts_world[i]
p2 = pts_world[(i + 1) % len(pts_world)]
if not p1.IsAlmostEqualTo(p2):
profile.Add(Line.CreateBound(p1, p2))
bx = final_transform.BasisX
wall_normal = XYZ(-bx.X, -bx.Y, -bx.Z)
new_wall = Wall.Create(
doc,
profile,
wall_type.Id,
level_id,
False,
wall_normal
)
doc.Regenerate()
# 벽 전체 Z 위치 지정
p_base = new_wall.get_Parameter(BuiltInParameter.WALL_BASE_OFFSET)
if p_base and (not p_base.IsReadOnly):
p_base.Set(final_base_z)
doc.Regenerate()
return new_wall
# =======================================================
# 6. 생성
# =======================================================
total_h = mm_to_feet(total_height_mm)
width = mm_to_feet(stair_width_mm)
tread = mm_to_feet(tread_depth_mm)
riser_h = total_h / step_count
active_view = doc.ActiveView
level = active_view.GenLevel
if not level:
p = active_view.get_Parameter(BuiltInParameter.PLAN_VIEW_LEVEL)
if p and p.AsElementId() != ElementId.InvalidElementId:
level = doc.GetElement(p.AsElementId())
if not level:
level = forms.SelectFromList.show(
sorted(FilteredElementCollector(doc).OfClass(Level), key=lambda x: x.Elevation),
name_attr="Name",
title=u"기준 레벨 선택"
)
if not level:
script.exit()
level_id = level.Id
t = Transaction(doc, "Create User Defined Stairs")
t.Start()
try:
current_loc_y = 0.0
current_z = start_offset_feet
for i in range(step_count):
# A. 챌판 (Wall)
local_p1 = XYZ(0, current_loc_y + half_mortar, 0)
local_p2 = XYZ(width, current_loc_y + half_mortar, 0)
p1 = final_transform.OfPoint(local_p1)
p2 = final_transform.OfPoint(local_p2)
new_wall = Wall.Create(
doc,
Line.CreateBound(p1, p2),
target_wall_type.Id,
level_id,
riser_h,
0,
False,
False
)
new_wall.get_Parameter(BuiltInParameter.WALL_BASE_OFFSET).Set(current_z)
# B. 디딤판 (Floor)
floor_z = current_z + riser_h + mortar_t
y0 = current_loc_y
y1 = current_loc_y + tread
pts = [
XYZ(0, y0, 0),
XYZ(width, y0, 0),
XYZ(width, y1, 0),
XYZ(0, y1, 0)
]
w_pts = [final_transform.OfPoint(p) for p in pts]
profile = CurveLoop()
for k in range(4):
profile.Append(Line.CreateBound(w_pts[k], w_pts[(k + 1) % 4]))
try:
new_floor = Floor.Create(doc, [profile], target_floor_type.Id, level_id)
except AttributeError:
ca = CurveArray()
for k in range(4):
ca.Append(Line.CreateBound(w_pts[k], w_pts[(k + 1) % 4]))
new_floor = doc.Create.NewFloor(ca, target_floor_type, level, False)
new_floor.get_Parameter(BuiltInParameter.FLOOR_HEIGHTABOVELEVEL_PARAM).Set(floor_z)
# C. 챌판 타일 (Wall)
tile_y = current_loc_y - half_tile
tp1 = final_transform.OfPoint(XYZ(0, tile_y, 0))
tp2 = final_transform.OfPoint(XYZ(width, tile_y, 0))
t_wall = Wall.Create(
doc,
Line.CreateBound(tp1, tp2),
tile_wall_type.Id,
level_id,
riser_h,
0,
False,
False
)
t_wall.get_Parameter(BuiltInParameter.WALL_BASE_OFFSET).Set(current_z + mortar_t)
# D. 디딤판 타일 (Floor)
ty0 = current_loc_y - tile_t
ty1 = current_loc_y + tread - tile_t
t_pts = [
XYZ(0, ty0, 0),
XYZ(width, ty0, 0),
XYZ(width, ty1, 0),
XYZ(0, ty1, 0)
]
tw_pts = [final_transform.OfPoint(p) for p in t_pts]
t_profile = CurveLoop()
for k in range(4):
t_profile.Append(Line.CreateBound(tw_pts[k], tw_pts[(k + 1) % 4]))
try:
t_floor = Floor.Create(doc, [t_profile], tile_floor_type.Id, level_id)
except AttributeError:
ca = CurveArray()
for k in range(4):
ca.Append(Line.CreateBound(tw_pts[k], tw_pts[(k + 1) % 4]))
t_floor = doc.Create.NewFloor(ca, tile_floor_type, level, False)
t_floor.get_Parameter(BuiltInParameter.FLOOR_HEIGHTABOVELEVEL_PARAM).Set(floor_z + tile_t)
current_loc_y += tread
current_z += riser_h
# =======================================================
# E. 밑면 경사판 (Floor 1개)
# 타입: 면손보기(노출면)#1T
# 위치:
# - 계단 하부선보다 underside_drop_mm 만큼 아래
# - 진행방향으로 (run_offset_base_mm + mortar_thick_mm) 만큼 이동
# =======================================================
total_run = step_count * tread
underside_start_z = start_offset_feet - underside_drop
create_underside_slope_floor(
doc=doc,
level=level,
level_id=level_id,
floor_type=underside_floor_type,
final_transform=final_transform,
width=width,
total_run=total_run,
start_z=underside_start_z,
total_h=total_h,
run_offset=underside_run_offset
)
# 최상단 타일 윗면 높이
left_finish_top_z = start_offset_feet + total_h + mortar_t + tile_t
create_left_side_profile_wall(
doc=doc,
level_id=level_id,
wall_type=underside_wall_type,
final_transform=final_transform,
run_offset=underside_run_offset,
step_count=step_count,
tread=tread,
riser_h=riser_h,
stair_start_z=start_offset_feet,
underside_start_z=underside_start_z,
total_h=total_h,
mortar_t=mortar_t,
tile_t=tile_t,
underside_drop=underside_drop
)
t.Commit()
except Exception as e:
t.RollBack()
forms.alert(u"에러 발생: " + str(e))
The Question:
Is there a known limitation or specific constraint behavior with Wall.Create using a profile (IList) when created near the project’s base level (Elevation 0) or below it? Why would the exact same geometric logic work flawlessly on Level 2 but fail on Level 1?
