diff --git a/index.html b/index.html
index 86aa673..da7f8c7 100644
--- a/index.html
+++ b/index.html
@@ -152,6 +152,13 @@
형상은 ② 형상계획 기준입니다. 형상이 바뀌면 "형상 바뀜"으로 표시합니다.
+
+
색 기준
+
+
+
+
+
철근표
diff --git a/src/core/rebar/color.ts b/src/core/rebar/color.ts
new file mode 100644
index 0000000..eed472f
--- /dev/null
+++ b/src/core/rebar/color.ts
@@ -0,0 +1,33 @@
+/**
+ * Colour keys of the ③ 3D rebar (spec §6.7). Pure: no three.js here. The palettes are in the view.
+ */
+export type RebarMemberKey = 'bearing' | 'coping' | 'column' | 'footing' | 'other';
+
+/** Member of a bar from its 철근표 name (C = coping, P = column, B = footing, S = bearing block, 커플러 = column). */
+export function memberOfBar(name: string): RebarMemberKey {
+ if (name.includes('커플러') || /^P\d/.test(name)) return 'column';
+ if (/^C\d/.test(name)) return 'coping';
+ if (/^B\d/.test(name)) return 'footing';
+ if (/^S\d/.test(name)) return 'bearing';
+ return 'other';
+}
+
+/**
+ * Nominal diameters (mm). H and S of the same size share one group (decision 2026-10-23): D13 12.7 · H16/S16 15.9 · …
+ * The order is the colour order of the 직경 mode.
+ */
+export const DIAMETER_GROUPS: readonly { mm: number; label: string }[] = [
+ { mm: 12.7, label: 'D13' },
+ { mm: 15.9, label: 'H16 · S16' },
+ { mm: 19.1, label: 'H19 · S19' },
+ { mm: 22.2, label: 'D22' },
+ { mm: 25.4, label: 'H25 · S25' },
+ { mm: 28.6, label: 'H29' },
+ { mm: 31.8, label: 'S32' },
+ { mm: 34.9, label: 'D35' },
+];
+
+/** Index into {@link DIAMETER_GROUPS}, or -1 for a diameter that is not a rebar size (e.g. a coupler). */
+export function diameterGroupIndex(dMm: number): number {
+ return DIAMETER_GROUPS.findIndex((g) => Math.abs(g.mm - dMm) < 0.05);
+}
diff --git a/src/main.ts b/src/main.ts
index f0a3d23..592d46b 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -12,7 +12,7 @@ import { applyCopy, DEFAULT_COPY_ITEMS, planCopy, type CopyItem } from './core/c
import { pileGeometry } from './core/geometry';
import { rebarSummary, type RebarRow } from './core/rebar/summary';
import { shearSegments } from './core/rebar/coping';
-import { buildRebar3D, type RebarScene } from './view/rebar3d';
+import { buildRebar3D, type RebarColorMode, type RebarScene } from './view/rebar3d';
import { DEFAULT_MATERIAL, FCK_MEMBERS, isAssumedMaterial, type FckByMember, type FckMember, type Material } from './core/rebar/common';
import { allVisibleChecked, setVisibleChecked, type TypeFilter } from './core/selection';
import { choose, defaultSummary, isSelectable, SHAPE_GROUPS, UNDEFINED_REASON, type MemberId } from './core/shape-options';
@@ -80,6 +80,8 @@ const state = {
fckByMember: {} as FckByMember,
/** Bar highlighted on ③ after [보기] (시안 ③ 알림). */
rebarFocus: null as string | null,
+ /** Colour basis of the 3D rebar on ③: role (default), diameter, or member. */
+ rebarColor: 'role' as RebarColorMode,
/** Member tab on ③ (철근표). */
rebarMember: 'coping' as RebarRow['member'],
/** Member card selected on ② (시안 ② 부재 목록). */
@@ -229,6 +231,7 @@ function setupScene(): void {
if (pier?.model) {
rebar3d = buildRebar3D(pier.model, state.material, state.fckByMember);
rebar3d.group.visible = state.stage === 'rebar';
+ rebar3d.setColorMode(state.rebarColor);
world.add(rebar3d.group);
const scene = buildPierScene(pier.model);
for (const key of Object.keys(state.visible) as PartName[]) {
@@ -415,6 +418,7 @@ function renderAll(): void {
renderEditCard();
renderPlan();
renderRebar();
+ renderRebarColor();
scheduleBackup();
}
@@ -765,6 +769,34 @@ const REBAR_MEMBERS: { id: RebarRow['member']; label: string }[] = [
{ id: 'footing', label: '확대기초' },
];
+/** 3D colour basis buttons on ③ and their legend (spec §6.7). */
+function renderRebarColor(): void {
+ for (const b of document.querySelectorAll('#rebar-color button')) {
+ b.setAttribute('aria-pressed', String(b.dataset.color === state.rebarColor));
+ }
+ const legend = $('rebar-legend');
+ legend.replaceChildren();
+ const items = rebar3d?.legend(state.rebarColor) ?? [];
+ if (state.rebarColor === 'role') {
+ legend.appendChild(el('li', '역할별 색 (명세 §6.7)', 'muted small'));
+ }
+ for (const item of items) {
+ const li = el('li', undefined, 'rebar-legend-item');
+ const swatch = el('span', undefined, 'swatch');
+ swatch.style.background = item.color;
+ li.append(swatch, el('span', item.label));
+ legend.appendChild(li);
+ }
+}
+
+for (const b of document.querySelectorAll('#rebar-color button')) {
+ b.addEventListener('click', () => {
+ state.rebarColor = b.dataset.color as RebarColorMode;
+ rebar3d?.setColorMode(state.rebarColor);
+ renderRebarColor();
+ });
+}
+
function renderRebar(): void {
const pier = activePier();
// 교각 목록 with the 배근 status (시안 ③). The checks are the same as in ① and ②.
diff --git a/src/style.css b/src/style.css
index 8b4dcdc..2491f5a 100644
--- a/src/style.css
+++ b/src/style.css
@@ -697,3 +697,11 @@ body { background: #eef1f4; font-family: 'Noto Sans KR', system-ui, sans-serif;
.center-col { flex: 999 1 560px; min-width: 0; display: flex; flex-direction: column; gap: 16px; }
.center-col > .viewer { flex: none; }
+
+/* ③ 3D 색 기준 (spec §6.7): 조작 요소 44px */
+.rebar-color { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; margin: 6px 0; }
+.rebar-color button { min-height: 44px; min-width: 56px; padding: 0 12px; border: 1px solid #b9c4d0; border-radius: 6px; background: #fff; cursor: pointer; }
+.rebar-color button[aria-pressed='true'] { background: #1f5fa8; border-color: #1f5fa8; color: #fff; }
+.rebar-legend { display: flex; flex-wrap: wrap; gap: 10px; list-style: none; margin: 0; padding: 0; font-size: 13px; }
+.rebar-legend-item { display: flex; align-items: center; gap: 6px; }
+.rebar-legend .swatch { display: inline-block; width: 14px; height: 14px; border-radius: 3px; }
diff --git a/src/view/rebar3d.ts b/src/view/rebar3d.ts
index 003359f..515d38a 100644
--- a/src/view/rebar3d.ts
+++ b/src/view/rebar3d.ts
@@ -6,6 +6,7 @@ import { C14_INSET, c14Bars, CAP, COPING_REBAR, shearBars, shearBarsMulti, shear
import { columnRebar, COLUMN_P1 } from '../core/rebar/column';
import { barsAcross, BLOCK_REBAR } from '../core/rebar/bearing-block';
import { DEFAULT_MATERIAL, FckByMember, hookLength, Material, materialFor, NOMINAL_DIAMETER } from '../core/rebar/common';
+import { diameterGroupIndex, DIAMETER_GROUPS, memberOfBar, type RebarMemberKey } from '../core/rebar/color';
import { stretchMatrix } from './scene';
/**
@@ -18,8 +19,21 @@ export interface RebarScene {
byName: Map;
/** Highlight one bar name (or none): the others are dimmed. */
highlight(name: string | null): void;
+ /** Colour the bars by role (default, spec §6.7), by nominal diameter, or by member. */
+ setColorMode(mode: RebarColorMode): void;
+ /** Legend entries of the mode, only for the items this model has. Role mode has none (the role colours are in §6.7). */
+ legend(mode: RebarColorMode): { label: string; color: string }[];
}
+export type RebarColorMode = 'role' | 'diameter' | 'member';
+
+/** Okabe–Ito colours (colour-blind safe), one per diameter group in order; grey for anything else. */
+const DIAMETER_COLORS = [0xe69f00, 0x56b4e9, 0x009e73, 0x0072b2, 0xd55e00, 0xcc79a7, 0x000000, 0x8c8c8c];
+const OTHER_COLOR = 0x8c8c8c;
+const MEMBER_COLORS: Record = { bearing: 0xe69f00, coping: 0x0072b2, column: 0x009e73, footing: 0xcc79a7, other: OTHER_COLOR };
+const MEMBER_LABELS: Record = { bearing: '받침블럭', coping: '코핑', column: '기둥', footing: '확대기초', other: '기타' };
+const hex = (v: number) => `#${v.toString(16).padStart(6, '0')}`;
+
const COLOR = {
coping: 0xb4531f,
shear: 0x6b3fa0,
@@ -54,7 +68,7 @@ export function buildRebar3D(m: PierModel, common: Material = DEFAULT_MATERIAL,
geo.applyMatrix4(Ms[k]);
const mat = new THREE.MeshStandardMaterial({ color, roughness: 0.6, metalness: 0.1 });
const mesh = new THREE.Mesh(geo, mat);
- mesh.userData.name = name;
+ mesh.userData = { name, roleColor: color, dMm, member: memberOfBar(name) };
group.add(mesh);
const list = byName.get(name) ?? [];
list.push(mesh);
@@ -436,5 +450,28 @@ export function buildRebar3D(m: PierModel, common: Material = DEFAULT_MATERIAL,
}
}
};
- return { group, byName, highlight };
+ const colorOf = (mesh: THREE.Mesh, mode: RebarColorMode): number => {
+ const data = mesh.userData as { roleColor: number; dMm: number; member: RebarMemberKey };
+ if (mode === 'role') return data.roleColor;
+ if (mode === 'member') return MEMBER_COLORS[data.member];
+ const idx = diameterGroupIndex(data.dMm);
+ return idx < 0 ? OTHER_COLOR : DIAMETER_COLORS[idx];
+ };
+ const setColorMode = (mode: RebarColorMode) => {
+ for (const meshes of byName.values()) {
+ for (const mesh of meshes) (mesh.material as THREE.MeshStandardMaterial).color.setHex(colorOf(mesh, mode));
+ }
+ };
+ const legend = (mode: RebarColorMode) => {
+ const all = [...byName.values()].flat().map((mesh) => mesh.userData as { dMm: number; member: RebarMemberKey });
+ if (mode === 'role') return [];
+ if (mode === 'member') {
+ const present = new Set(all.map((d) => d.member));
+ return (Object.keys(MEMBER_LABELS) as RebarMemberKey[]).filter((k) => present.has(k)).map((k) => ({ label: MEMBER_LABELS[k], color: hex(MEMBER_COLORS[k]) }));
+ }
+ const present = new Set(all.map((d) => diameterGroupIndex(d.dMm)));
+ const items = DIAMETER_GROUPS.map((g, i) => ({ label: g.label, color: hex(DIAMETER_COLORS[i]) })).filter((_, i) => present.has(i));
+ return present.has(-1) ? [...items, { label: '기타', color: hex(OTHER_COLOR) }] : items;
+ };
+ return { group, byName, highlight, setColorMode, legend };
}
diff --git a/tests/rebar-color.test.ts b/tests/rebar-color.test.ts
new file mode 100644
index 0000000..7d82080
--- /dev/null
+++ b/tests/rebar-color.test.ts
@@ -0,0 +1,56 @@
+import { describe, expect, it } from 'vitest';
+import * as THREE from 'three';
+import { buildPier } from '../src/core/geometry';
+import { DIAMETER_GROUPS, diameterGroupIndex, memberOfBar } from '../src/core/rebar/color';
+import { buildRebar3D } from '../src/view/rebar3d';
+import { loadP1 } from './helpers';
+
+const rb = buildRebar3D(buildPier(loadP1()));
+const colorOf = (name: string) => (rb.byName.get(name)![0].material as THREE.MeshStandardMaterial).color.getHex();
+
+describe('③ 3D 색 기준 (spec §6.7 승인 2026-10-23)', () => {
+ it('every bar of P1 belongs to a known member (none falls into 기타)', () => {
+ const names = [...rb.byName.keys()];
+ expect(names.filter((n) => memberOfBar(n) === 'other')).toEqual([]);
+ });
+
+ it('H and S of the same size share one diameter group; a non-rebar diameter has none', () => {
+ expect(DIAMETER_GROUPS[diameterGroupIndex(15.9)].label).toBe('H16 · S16');
+ expect(DIAMETER_GROUPS[diameterGroupIndex(25.4)].label).toBe('H25 · S25');
+ expect(DIAMETER_GROUPS).toHaveLength(8);
+ expect(diameterGroupIndex(60)).toBe(-1);
+ });
+
+ it('role is the default colour; diameter and member modes recolour every bar and can switch back', () => {
+ const role = colorOf('C1');
+ rb.setColorMode('member');
+ expect(colorOf('C1')).not.toBe(role);
+ rb.setColorMode('diameter');
+ expect(colorOf('C1')).not.toBe(role);
+ rb.setColorMode('role');
+ expect(colorOf('C1')).toBe(role);
+ });
+
+ it('member mode: all bars of one member share one colour', () => {
+ rb.setColorMode('member');
+ expect(colorOf('C1')).toBe(colorOf('C14'));
+ expect(colorOf('P1')).toBe(colorOf('P6'));
+ expect(colorOf('C1')).not.toBe(colorOf('P1'));
+ rb.setColorMode('role');
+ });
+
+ it('diameter mode: bars of one size share a colour, a different size does not (C1 and C7 are H29, C9 is H25)', () => {
+ rb.setColorMode('diameter');
+ expect(colorOf('C1')).toBe(colorOf('C7'));
+ expect(colorOf('C1')).not.toBe(colorOf('C9'));
+ rb.setColorMode('role');
+ });
+
+ it('legend lists only the items the model has; role mode has none', () => {
+ expect(rb.legend('role')).toEqual([]);
+ expect(rb.legend('member').map((i) => i.label)).toEqual(['받침블럭', '코핑', '기둥', '확대기초']);
+ const sizes = rb.legend('diameter').map((i) => i.label);
+ expect(sizes.length).toBeGreaterThan(0);
+ expect(sizes.length).toBeLessThanOrEqual(8);
+ });
+});