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); + }); +});