Align DICOM UPP registration uniqueness
This commit is contained in:
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
用于在浏览器中人工核对 DICOM 序列与 UPP STL 模型的配准关系。页面会读取 PACS DICOM、UPP STL 资产和 DICOM 阅片分类结果,配准位姿单独写入 `dicom_upp_registrations` 表。
|
用于在浏览器中人工核对 DICOM 序列与 UPP STL 模型的配准关系。页面会读取 PACS DICOM、UPP STL 资产和 DICOM 阅片分类结果,配准位姿单独写入 `dicom_upp_registrations` 表。
|
||||||
|
|
||||||
|
配准维护表按 `ct_number + algorithm_model` 建唯一约束:同一个 CT、同一个算法模型只保留一条 STL 配准记录;再次保存会更新原记录。
|
||||||
|
|
||||||
默认端口:
|
默认端口:
|
||||||
|
|
||||||
- 本机:`http://127.0.0.1:8109/`
|
- 本机:`http://127.0.0.1:8109/`
|
||||||
|
|||||||
@@ -198,9 +198,95 @@ def ensure_registration_table() -> None:
|
|||||||
updated_by text NOT NULL DEFAULT 'admin',
|
updated_by text NOT NULL DEFAULT 'admin',
|
||||||
created_at timestamptz NOT NULL DEFAULT now(),
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
UNIQUE (ct_number, series_instance_uid, algorithm_model, stl_family)
|
UNIQUE (ct_number, algorithm_model)
|
||||||
);
|
);
|
||||||
|
ALTER TABLE public.{REGISTRATION_TABLE_SQL}
|
||||||
|
ADD COLUMN IF NOT EXISTS series_instance_uid text NOT NULL DEFAULT '',
|
||||||
|
ADD COLUMN IF NOT EXISTS series_description text NOT NULL DEFAULT '',
|
||||||
|
ADD COLUMN IF NOT EXISTS algorithm_model text NOT NULL DEFAULT '',
|
||||||
|
ADD COLUMN IF NOT EXISTS stl_family text NOT NULL DEFAULT '',
|
||||||
|
ADD COLUMN IF NOT EXISTS view_mode text NOT NULL DEFAULT 'family',
|
||||||
|
ADD COLUMN IF NOT EXISTS selected_stl_files jsonb NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
ADD COLUMN IF NOT EXISTS transform jsonb NOT NULL DEFAULT '{{}}'::jsonb,
|
||||||
|
ADD COLUMN IF NOT EXISTS dicom_reference jsonb NOT NULL DEFAULT '{{}}'::jsonb,
|
||||||
|
ADD COLUMN IF NOT EXISTS model_reference jsonb NOT NULL DEFAULT '{{}}'::jsonb,
|
||||||
|
ADD COLUMN IF NOT EXISTS segmentation jsonb NOT NULL DEFAULT '{{}}'::jsonb,
|
||||||
|
ADD COLUMN IF NOT EXISTS notes text NOT NULL DEFAULT '',
|
||||||
|
ADD COLUMN IF NOT EXISTS locked boolean NOT NULL DEFAULT false,
|
||||||
|
ADD COLUMN IF NOT EXISTS locked_by text,
|
||||||
|
ADD COLUMN IF NOT EXISTS locked_at timestamptz,
|
||||||
|
ADD COLUMN IF NOT EXISTS updated_by text NOT NULL DEFAULT 'admin',
|
||||||
|
ADD COLUMN IF NOT EXISTS created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now();
|
||||||
|
UPDATE public.{REGISTRATION_TABLE_SQL}
|
||||||
|
SET
|
||||||
|
ct_number = upper(regexp_replace(COALESCE(ct_number, ''), '\\s+', '', 'g')),
|
||||||
|
algorithm_model = COALESCE(NULLIF(btrim(algorithm_model), ''), '未指定模型'),
|
||||||
|
stl_family = COALESCE(NULLIF(btrim(stl_family), ''), '全部STL')
|
||||||
|
WHERE ct_number <> upper(regexp_replace(COALESCE(ct_number, ''), '\\s+', '', 'g'))
|
||||||
|
OR algorithm_model = ''
|
||||||
|
OR stl_family = '';
|
||||||
|
CREATE TABLE IF NOT EXISTS public.{REGISTRATION_TABLE_SQL}_duplicate_archive (
|
||||||
|
archived_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
reason text NOT NULL,
|
||||||
|
row_data jsonb NOT NULL
|
||||||
|
);
|
||||||
|
WITH ranked AS (
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
row_number() OVER (
|
||||||
|
PARTITION BY ct_number, algorithm_model
|
||||||
|
ORDER BY locked DESC, updated_at DESC NULLS LAST, id DESC
|
||||||
|
) AS rn
|
||||||
|
FROM public.{REGISTRATION_TABLE_SQL}
|
||||||
|
),
|
||||||
|
duplicates AS (
|
||||||
|
SELECT t.*
|
||||||
|
FROM public.{REGISTRATION_TABLE_SQL} t
|
||||||
|
JOIN ranked r ON r.id = t.id
|
||||||
|
WHERE r.rn > 1
|
||||||
|
)
|
||||||
|
INSERT INTO public.{REGISTRATION_TABLE_SQL}_duplicate_archive(reason, row_data)
|
||||||
|
SELECT 'ct_algorithm_unique_migration', row_to_json(duplicates)::jsonb
|
||||||
|
FROM duplicates;
|
||||||
|
WITH ranked AS (
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
row_number() OVER (
|
||||||
|
PARTITION BY ct_number, algorithm_model
|
||||||
|
ORDER BY locked DESC, updated_at DESC NULLS LAST, id DESC
|
||||||
|
) AS rn
|
||||||
|
FROM public.{REGISTRATION_TABLE_SQL}
|
||||||
|
)
|
||||||
|
DELETE FROM public.{REGISTRATION_TABLE_SQL} t
|
||||||
|
USING ranked r
|
||||||
|
WHERE t.id = r.id AND r.rn > 1;
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
old_constraint record;
|
||||||
|
BEGIN
|
||||||
|
FOR old_constraint IN
|
||||||
|
SELECT conname
|
||||||
|
FROM pg_constraint
|
||||||
|
WHERE conrelid = 'public.{REGISTRATION_TABLE_SQL}'::regclass
|
||||||
|
AND contype = 'u'
|
||||||
|
AND pg_get_constraintdef(oid) LIKE '%(ct_number, series_instance_uid, algorithm_model, stl_family)%'
|
||||||
|
LOOP
|
||||||
|
EXECUTE format('ALTER TABLE public.{REGISTRATION_TABLE_SQL} DROP CONSTRAINT %I', old_constraint.conname);
|
||||||
|
END LOOP;
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM pg_constraint
|
||||||
|
WHERE conrelid = 'public.{REGISTRATION_TABLE_SQL}'::regclass
|
||||||
|
AND contype = 'u'
|
||||||
|
AND conname = '{REGISTRATION_TABLE_SQL}_ct_algorithm_key'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE public.{REGISTRATION_TABLE_SQL}
|
||||||
|
ADD CONSTRAINT {REGISTRATION_TABLE_SQL}_ct_algorithm_key UNIQUE (ct_number, algorithm_model);
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
CREATE INDEX IF NOT EXISTS idx_{REGISTRATION_TABLE_SQL}_ct ON public.{REGISTRATION_TABLE_SQL}(ct_number);
|
CREATE INDEX IF NOT EXISTS idx_{REGISTRATION_TABLE_SQL}_ct ON public.{REGISTRATION_TABLE_SQL}(ct_number);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_{REGISTRATION_TABLE_SQL}_algorithm ON public.{REGISTRATION_TABLE_SQL}(algorithm_model);
|
||||||
CREATE INDEX IF NOT EXISTS idx_{REGISTRATION_TABLE_SQL}_locked ON public.{REGISTRATION_TABLE_SQL}(locked);
|
CREATE INDEX IF NOT EXISTS idx_{REGISTRATION_TABLE_SQL}_locked ON public.{REGISTRATION_TABLE_SQL}(locked);
|
||||||
""",
|
""",
|
||||||
timeout=12,
|
timeout=12,
|
||||||
@@ -852,11 +938,14 @@ def stl_rows_for_ct(ct_number: str) -> list[dict[str, Any]]:
|
|||||||
SELECT
|
SELECT
|
||||||
COALESCE(u.algorithm_model, '未指定模型') AS algorithm_model,
|
COALESCE(u.algorithm_model, '未指定模型') AS algorithm_model,
|
||||||
u.processed_stl_dir,
|
u.processed_stl_dir,
|
||||||
COALESCE(s.files, u.stl_files, '[]'::jsonb) AS files
|
CASE
|
||||||
|
WHEN jsonb_typeof(u.stl_files) = 'array' AND jsonb_array_length(u.stl_files) > 0 THEN u.stl_files
|
||||||
|
ELSE COALESCE(s.files, '[]'::jsonb)
|
||||||
|
END AS files
|
||||||
FROM public.{UPP_ASSET_TABLE_SQL} u
|
FROM public.{UPP_ASSET_TABLE_SQL} u
|
||||||
LEFT JOIN public.{UPP_STL_TABLE_SQL} s ON upper(s.ct_number) = upper(u.ct_number)
|
LEFT JOIN public.{UPP_STL_TABLE_SQL} s ON upper(s.ct_number) = upper(u.ct_number)
|
||||||
WHERE upper(u.ct_number) = {sql_literal(normalize_ct(ct_number))}
|
WHERE upper(u.ct_number) = {sql_literal(normalize_ct(ct_number))}
|
||||||
ORDER BY u.updated_at DESC NULLS LAST
|
ORDER BY COALESCE(u.algorithm_model, '未指定模型'), u.updated_at DESC NULLS LAST
|
||||||
""",
|
""",
|
||||||
timeout=14,
|
timeout=14,
|
||||||
)
|
)
|
||||||
@@ -950,9 +1039,7 @@ def save_registration(payload: RegistrationPayload, user: str = Depends(require_
|
|||||||
SELECT locked
|
SELECT locked
|
||||||
FROM public.{REGISTRATION_TABLE_SQL}
|
FROM public.{REGISTRATION_TABLE_SQL}
|
||||||
WHERE ct_number = {sql_literal(ct_number)}
|
WHERE ct_number = {sql_literal(ct_number)}
|
||||||
AND series_instance_uid = {sql_literal(series_uid)}
|
|
||||||
AND algorithm_model = {sql_literal(algorithm_model)}
|
AND algorithm_model = {sql_literal(algorithm_model)}
|
||||||
AND stl_family = {sql_literal(stl_family)}
|
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
""",
|
""",
|
||||||
timeout=8,
|
timeout=8,
|
||||||
@@ -989,8 +1076,10 @@ def save_registration(payload: RegistrationPayload, user: str = Depends(require_
|
|||||||
{sql_literal(user)},
|
{sql_literal(user)},
|
||||||
now()
|
now()
|
||||||
)
|
)
|
||||||
ON CONFLICT (ct_number, series_instance_uid, algorithm_model, stl_family) DO UPDATE SET
|
ON CONFLICT (ct_number, algorithm_model) DO UPDATE SET
|
||||||
|
series_instance_uid = EXCLUDED.series_instance_uid,
|
||||||
series_description = EXCLUDED.series_description,
|
series_description = EXCLUDED.series_description,
|
||||||
|
stl_family = EXCLUDED.stl_family,
|
||||||
view_mode = EXCLUDED.view_mode,
|
view_mode = EXCLUDED.view_mode,
|
||||||
selected_stl_files = EXCLUDED.selected_stl_files,
|
selected_stl_files = EXCLUDED.selected_stl_files,
|
||||||
transform = EXCLUDED.transform,
|
transform = EXCLUDED.transform,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ const state = {
|
|||||||
stlFiles: [],
|
stlFiles: [],
|
||||||
selectedStlIds: new Set(),
|
selectedStlIds: new Set(),
|
||||||
selectedFamilies: new Set(),
|
selectedFamilies: new Set(),
|
||||||
|
selectedAlgorithmModel: "",
|
||||||
mode: "file",
|
mode: "file",
|
||||||
registrations: [],
|
registrations: [],
|
||||||
pose: defaultPose(),
|
pose: defaultPose(),
|
||||||
@@ -335,15 +336,46 @@ async function selectCase(ctKey) {
|
|||||||
function chooseDefaultStl() {
|
function chooseDefaultStl() {
|
||||||
state.selectedStlIds.clear();
|
state.selectedStlIds.clear();
|
||||||
state.selectedFamilies.clear();
|
state.selectedFamilies.clear();
|
||||||
if (!state.stlFiles.length) return;
|
if (!state.stlFiles.length) {
|
||||||
const families = Array.from(new Set(state.stlFiles.map((item) => item.family).filter(Boolean)));
|
state.selectedAlgorithmModel = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const models = algorithmModels();
|
||||||
|
const nextModel = models.includes(state.selectedAlgorithmModel) ? state.selectedAlgorithmModel : models[0] || "";
|
||||||
|
selectDefaultStlForModel(nextModel);
|
||||||
|
}
|
||||||
|
|
||||||
|
function algorithmModels() {
|
||||||
|
return Array.from(new Set(state.stlFiles.map((item) => item.algorithm_model || "未指定模型").filter(Boolean))).sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentAlgorithmModel() {
|
||||||
|
const selected = selectedStlFiles();
|
||||||
|
const selectedModel = selected.find((item) => item.algorithm_model)?.algorithm_model;
|
||||||
|
if (selectedModel) return selectedModel;
|
||||||
|
if (state.selectedAlgorithmModel) return state.selectedAlgorithmModel;
|
||||||
|
return algorithmModels()[0] || state.activeCase?.algorithm_model || "未指定模型";
|
||||||
|
}
|
||||||
|
|
||||||
|
function stlFilesForSelectedModel() {
|
||||||
|
const model = currentAlgorithmModel();
|
||||||
|
return model ? state.stlFiles.filter((item) => (item.algorithm_model || "未指定模型") === model) : state.stlFiles;
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectDefaultStlForModel(model) {
|
||||||
|
state.selectedStlIds.clear();
|
||||||
|
state.selectedFamilies.clear();
|
||||||
|
state.selectedAlgorithmModel = model || algorithmModels()[0] || "";
|
||||||
|
const candidates = stlFilesForSelectedModel();
|
||||||
|
if (!candidates.length) return;
|
||||||
|
const families = Array.from(new Set(candidates.map((item) => item.family).filter(Boolean)));
|
||||||
const preferred = ["liver", "portal_vein", "liver_artery", "liver_vein", "bile_duct", "pancreas", "spleen"];
|
const preferred = ["liver", "portal_vein", "liver_artery", "liver_vein", "bile_duct", "pancreas", "spleen"];
|
||||||
const selectedFamily = preferred.find((family) => families.includes(family)) || families[0];
|
const selectedFamily = preferred.find((family) => families.includes(family)) || families[0];
|
||||||
if (selectedFamily) {
|
if (selectedFamily) {
|
||||||
state.selectedFamilies.add(selectedFamily);
|
state.selectedFamilies.add(selectedFamily);
|
||||||
state.stlFiles.filter((item) => item.family === selectedFamily).forEach((item) => state.selectedStlIds.add(Number(item.id)));
|
candidates.filter((item) => item.family === selectedFamily).forEach((item) => state.selectedStlIds.add(Number(item.id)));
|
||||||
} else {
|
} else {
|
||||||
state.selectedStlIds.add(Number(state.stlFiles[0].id));
|
state.selectedStlIds.add(Number(candidates[0].id));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -423,24 +455,40 @@ function renderStl() {
|
|||||||
$("stlCount").textContent = String(state.stlFiles.length);
|
$("stlCount").textContent = String(state.stlFiles.length);
|
||||||
$("modeFile").classList.toggle("active", state.mode === "file");
|
$("modeFile").classList.toggle("active", state.mode === "file");
|
||||||
$("modeFamily").classList.toggle("active", state.mode === "family");
|
$("modeFamily").classList.toggle("active", state.mode === "family");
|
||||||
const families = Array.from(new Set(state.stlFiles.map((item) => item.family).filter(Boolean)));
|
const models = algorithmModels();
|
||||||
|
if (models.length && !models.includes(state.selectedAlgorithmModel)) {
|
||||||
|
state.selectedAlgorithmModel = models[0];
|
||||||
|
}
|
||||||
|
$("modelRail").innerHTML = models
|
||||||
|
.map((model) => `<button class="chip ${model === currentAlgorithmModel() ? "active" : ""}" data-model="${escapeHtml(model)}" type="button">${escapeHtml(model)}</button>`)
|
||||||
|
.join("");
|
||||||
|
const visibleFiles = stlFilesForSelectedModel();
|
||||||
|
const families = Array.from(new Set(visibleFiles.map((item) => item.family).filter(Boolean)));
|
||||||
$("familyRail").innerHTML = families
|
$("familyRail").innerHTML = families
|
||||||
.map((family) => `<button class="chip ${state.selectedFamilies.has(family) ? "active" : ""}" data-family="${escapeHtml(family)}" type="button">${escapeHtml(family)}</button>`)
|
.map((family) => `<button class="chip ${state.selectedFamilies.has(family) ? "active" : ""}" data-family="${escapeHtml(family)}" type="button">${escapeHtml(family)}</button>`)
|
||||||
.join("");
|
.join("");
|
||||||
|
document.querySelectorAll("[data-model]").forEach((button) => {
|
||||||
|
button.addEventListener("click", async () => {
|
||||||
|
selectDefaultStlForModel(button.dataset.model);
|
||||||
|
markUnsaved();
|
||||||
|
renderStl();
|
||||||
|
await reloadStlVisuals();
|
||||||
|
});
|
||||||
|
});
|
||||||
document.querySelectorAll("[data-family]").forEach((button) => {
|
document.querySelectorAll("[data-family]").forEach((button) => {
|
||||||
button.addEventListener("click", async () => {
|
button.addEventListener("click", async () => {
|
||||||
const family = button.dataset.family;
|
const family = button.dataset.family;
|
||||||
if (state.selectedFamilies.has(family)) state.selectedFamilies.delete(family);
|
if (state.selectedFamilies.has(family)) state.selectedFamilies.delete(family);
|
||||||
else state.selectedFamilies.add(family);
|
else state.selectedFamilies.add(family);
|
||||||
state.selectedStlIds.clear();
|
state.selectedStlIds.clear();
|
||||||
state.stlFiles.filter((item) => state.selectedFamilies.has(item.family)).forEach((item) => state.selectedStlIds.add(Number(item.id)));
|
visibleFiles.filter((item) => state.selectedFamilies.has(item.family)).forEach((item) => state.selectedStlIds.add(Number(item.id)));
|
||||||
markUnsaved();
|
markUnsaved();
|
||||||
renderStl();
|
renderStl();
|
||||||
await reloadStlVisuals();
|
await reloadStlVisuals();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
$("stlList").innerHTML = state.stlFiles.length
|
$("stlList").innerHTML = visibleFiles.length
|
||||||
? state.stlFiles
|
? visibleFiles
|
||||||
.map((row) => {
|
.map((row) => {
|
||||||
const checked = state.selectedStlIds.has(Number(row.id));
|
const checked = state.selectedStlIds.has(Number(row.id));
|
||||||
return `
|
return `
|
||||||
@@ -533,9 +581,11 @@ async function reloadStlVisuals() {
|
|||||||
|
|
||||||
function applyLatestRegistration() {
|
function applyLatestRegistration() {
|
||||||
const seriesUid = state.selectedSeries?.series_uid || "";
|
const seriesUid = state.selectedSeries?.series_uid || "";
|
||||||
const selectedLabel = currentStlLabel();
|
const model = currentAlgorithmModel();
|
||||||
const exact = state.registrations.find((item) => item.series_instance_uid === seriesUid && item.stl_family === selectedLabel);
|
const latest =
|
||||||
const latest = exact || state.registrations.find((item) => item.series_instance_uid === seriesUid) || null;
|
state.registrations.find((item) => item.algorithm_model === model) ||
|
||||||
|
state.registrations.find((item) => item.series_instance_uid === seriesUid) ||
|
||||||
|
null;
|
||||||
if (latest?.transform) {
|
if (latest?.transform) {
|
||||||
state.pose = { ...defaultPose(), ...latest.transform };
|
state.pose = { ...defaultPose(), ...latest.transform };
|
||||||
$("registrationNotes").value = latest.notes || "";
|
$("registrationNotes").value = latest.notes || "";
|
||||||
@@ -557,11 +607,16 @@ async function saveRegistration(locked = false, forceUnlock = false) {
|
|||||||
alert("请至少选择一个 STL 构件");
|
alert("请至少选择一个 STL 构件");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const selectedModels = Array.from(new Set(selected.map((item) => item.algorithm_model || "未指定模型")));
|
||||||
|
if (selectedModels.length > 1) {
|
||||||
|
alert("同一次配准只能选择同一个算法模型的 STL");
|
||||||
|
return;
|
||||||
|
}
|
||||||
const payload = {
|
const payload = {
|
||||||
ct_number: state.activeCase.pacs_ct_number || state.activeCase.ct_key,
|
ct_number: state.activeCase.pacs_ct_number || state.activeCase.ct_key,
|
||||||
series_instance_uid: state.selectedSeries.series_uid,
|
series_instance_uid: state.selectedSeries.series_uid,
|
||||||
series_description: state.selectedSeries.description || "",
|
series_description: state.selectedSeries.description || "",
|
||||||
algorithm_model: state.activeCase.algorithm_model || selected[0]?.algorithm_model || "未指定模型",
|
algorithm_model: selectedModels[0] || currentAlgorithmModel(),
|
||||||
stl_family: currentStlLabel(),
|
stl_family: currentStlLabel(),
|
||||||
view_mode: state.mode,
|
view_mode: state.mode,
|
||||||
selected_stl_files: selected.map((item) => ({
|
selected_stl_files: selected.map((item) => ({
|
||||||
@@ -584,7 +639,7 @@ async function saveRegistration(locked = false, forceUnlock = false) {
|
|||||||
slice_thickness: state.selectedSeries.slice_thickness,
|
slice_thickness: state.selectedSeries.slice_thickness,
|
||||||
spacing_between_slices: state.selectedSeries.spacing_between_slices,
|
spacing_between_slices: state.selectedSeries.spacing_between_slices,
|
||||||
},
|
},
|
||||||
model_reference: { selected_count: selected.length, families: Array.from(state.selectedFamilies) },
|
model_reference: { algorithm_model: selectedModels[0] || currentAlgorithmModel(), selected_count: selected.length, families: Array.from(state.selectedFamilies) },
|
||||||
segmentation: { preview: state.maskVisible, source: "visible_stl_preview" },
|
segmentation: { preview: state.maskVisible, source: "visible_stl_preview" },
|
||||||
notes: $("registrationNotes").value.trim(),
|
notes: $("registrationNotes").value.trim(),
|
||||||
locked,
|
locked,
|
||||||
|
|||||||
@@ -61,6 +61,7 @@
|
|||||||
<button id="modeFile" class="active" type="button">单独</button>
|
<button id="modeFile" class="active" type="button">单独</button>
|
||||||
<button id="modeFamily" type="button">Family</button>
|
<button id="modeFamily" type="button">Family</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="modelRail" class="chip-rail"></div>
|
||||||
<div id="familyRail" class="chip-rail"></div>
|
<div id="familyRail" class="chip-rail"></div>
|
||||||
<div id="stlList" class="stl-list"></div>
|
<div id="stlList" class="stl-list"></div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
Reference in New Issue
Block a user