- Code\rebar_pipeline, review_pipeline, pier-program을 정션에서 실제 폴더로 전환 - Code\rhino_mcp 및 CLAUDE_rhino배경.md 추가, .mcp.json은 PYTHONPATH로 새 위치 사용 - pier-program 내부 .git 제거(이력은 backup\pier-program_20261028.bundle에 보관) - worktree 가이드 반영(CLAUDE.local.md, HANDOFF.md) - D:\Rhino-MCP 경로 참조를 새 위치로 치환 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
118 lines
7.6 KiB
Python
118 lines
7.6 KiB
Python
"""Publish a strict duplicate-only subset from a current-source replay."""
|
|
import argparse, copy, hashlib, json, os, sys, tempfile, time
|
|
from pathlib import Path
|
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
|
from slab_duplicate_reconciliation import reconcile
|
|
|
|
|
|
def main():
|
|
p=argparse.ArgumentParser(description=__doc__)
|
|
p.add_argument('job',type=Path);p.add_argument('candidate',type=Path)
|
|
p.add_argument('--replay-input',type=Path)
|
|
p.add_argument('--stacked-additions',action='store_true',help='Only add independently witnessed stacked section dots')
|
|
p.add_argument('--source-faces',action='store_true',help='Replace CAD-replayed slab faces after pair and tube checks')
|
|
p.add_argument('--source-walls',action='store_true',help='Replace wall rows after own elevation and tube checks')
|
|
p.add_argument('--group-id',action='append',help='Publish only these verified groups; retain full replay pair checks')
|
|
p.add_argument('--apply',action='store_true');a=p.parse_args()
|
|
import server
|
|
server.require_modeling_rules()
|
|
raw=a.job.read_bytes();job=json.loads(raw);candidate=json.loads(a.candidate.read_text())
|
|
if job.get('status')=='running':raise ValueError('active worker')
|
|
replay_raw=a.replay_input.read_bytes() if a.replay_input else raw
|
|
if hashlib.sha256(replay_raw).hexdigest()!=candidate['source_job_sha256'] and not a.stacked_additions:
|
|
raise ValueError('job differs from replay input; recheck before publishing')
|
|
# Add-only reconciliation independently requires identical concrete and
|
|
# every current target path to remain in the candidate. CAD hashes and
|
|
# added block coordinates are checked below; unrelated job metadata is
|
|
# not authority to discard a verified additional occurrence.
|
|
baseline=json.loads(replay_raw)['result']
|
|
def geometry(plan):
|
|
return [plan['concrete_components'],[{k:g.get(k)for k in
|
|
('id','diameter_mm','count','instance_paths_m','occurrence_instance_ids','host_component_ids')}
|
|
for g in plan['rebar_groups']]]
|
|
if geometry(baseline)!=geometry(job['result']):
|
|
# Allow an idempotent continuation of this exact candidate only.
|
|
# Any other intervening geometry still rejects publication.
|
|
known={g.get('id'):g for g in candidate['rebar_groups']}
|
|
normalized=copy.deepcopy(job['result'])
|
|
original={g.get('id'):g for g in baseline['rebar_groups']}
|
|
keys=('id','diameter_mm','count','instance_paths_m','occurrence_instance_ids','host_component_ids')
|
|
for i,g in enumerate(normalized['rebar_groups']):
|
|
k=g.get('id');old=original.get(k)
|
|
if old and (a.source_faces or a.stacked_additions) and k in known and all(g.get(key)==known[k].get(key)for key in keys):
|
|
normalized['rebar_groups'][i]=copy.deepcopy(old)
|
|
if a.stacked_additions or a.source_faces or a.source_walls:
|
|
if baseline['concrete_components']!=job['result']['concrete_components']:
|
|
raise ValueError('concrete changed since source replay')
|
|
current={g['id']:g for g in job['result']['rebar_groups']}
|
|
for identity,new in known.items():
|
|
old=original.get(identity);now=current.get(identity)
|
|
if old and (now is None or not any(all(now.get(k)==ref.get(k)for k in keys) for ref in (old,new))):
|
|
raise ValueError('target face changed since source replay')
|
|
elif geometry(baseline)!=geometry(normalized):raise ValueError('geometry changed since source replay')
|
|
from cad_source_access import source_manifest
|
|
sources=source_manifest(job['sourceContext']['analysis'],server.UPLOAD_ROOT)['sources']
|
|
if sorted(s['sha256']for s in sources)!=sorted(candidate['source_hashes']):
|
|
raise ValueError('original CAD differs from replay input')
|
|
# Re-read the wall-view leaders, not the saved wall allocation. A world
|
|
# overlap alone cannot authorize removal of a slab occurrence.
|
|
from cad_rebar_recipe import DrawingSource
|
|
from cad_source_catalog import normalized
|
|
from local_wall_elevation_ownership import bind_frame_ownership
|
|
drawing_sources={s['sha256']:DrawingSource(Path(s['path']).read_bytes())for s in sources}
|
|
for group in candidate['rebar_groups']:
|
|
section=group.get('standard_section_placement',{})
|
|
for placement in section.get('placements',[]):
|
|
if placement.get('source_domain')!='drawn_inner_face_block':continue
|
|
from local_face_layout_discovery import symbol_center
|
|
source=drawing_sources[section['source_sha256']]
|
|
entity=source.entity(placement['entity'])
|
|
if entity.get('type')!='CIRCLE':raise ValueError('additional occurrence is not an original circle block')
|
|
center,_=symbol_center(entity)
|
|
proof=placement['source_coordinate_proof']
|
|
if any(abs(a-b)>1e-5 for a,b in zip(center,proof['center_xy'])):
|
|
raise ValueError('additional block coordinate differs from original CAD')
|
|
datum=section['datum'];datum_entity=source.entity(datum['entity'])
|
|
if abs(datum_entity['points'][datum['vertex']][1]-proof['datum_top'])>1e-5:
|
|
raise ValueError('additional block datum differs from original CAD')
|
|
row=group.get('standard_section_placement',{}).get('row_binding',{})
|
|
if row.get('installation_kind')!='wall' or not row.get('longitudinal_ends'):continue
|
|
frame={'source_wall_end_bindings':{row['host_role']:row['longitudinal_ends']}}
|
|
proof=row['longitudinal_ends']['start']['source']
|
|
scale=proof.get('cad_to_m') or proof.get('chain',{}).get('cad_to_mm',0)/1000
|
|
if not scale:continue
|
|
ownership=bind_frame_ownership(frame,drawing_sources,scale)
|
|
row['wall_elevation_occurrences']=[r for r in ownership if r['mark']==normalized(group['mark'])]
|
|
reconcile_candidate=reconcile
|
|
if a.stacked_additions:
|
|
from slab_stacked_reconciliation import reconcile as reconcile_candidate
|
|
if a.source_faces:
|
|
if a.stacked_additions:raise ValueError('choose one scoped publication mode')
|
|
from slab_source_reconciliation import reconcile as reconcile_candidate
|
|
if a.source_walls:
|
|
if a.source_faces or a.stacked_additions:raise ValueError('choose one scoped publication mode')
|
|
from wall_source_reconciliation import reconcile as reconcile_candidate
|
|
if a.group_id:
|
|
requested=set(a.group_id)
|
|
available={g['id'] for g in candidate['rebar_groups']}
|
|
if not requested<=available:raise ValueError('requested group absent from source replay')
|
|
candidate=copy.deepcopy(candidate)
|
|
candidate['rebar_groups']=[g for g in candidate['rebar_groups'] if g['id'] in requested]
|
|
result,report=reconcile_candidate(job['result'],candidate)
|
|
if a.apply and report['changed_groups']:
|
|
from box_member_schema import member_model
|
|
result['memberModel']=member_model(result)
|
|
job.update(result=result,partialResult=copy.deepcopy(result),geometryRevision=time.time_ns())
|
|
if a.job.read_bytes()!=raw:raise ValueError('concurrent job change')
|
|
backup=a.job.with_name(a.job.stem+f'.before-slab-duplicate-{time.time_ns()}.json')
|
|
with backup.open('xb')as f:f.write(raw)
|
|
with tempfile.NamedTemporaryFile(mode='w',dir=a.job.parent,delete=False)as f:
|
|
json.dump(job,f,ensure_ascii=False,allow_nan=False);temporary=f.name
|
|
if a.job.read_bytes()!=raw:raise ValueError('concurrent job change before replacement')
|
|
os.replace(temporary,a.job);report['backup']=str(backup)
|
|
report['published']=bool(a.apply and report['changed_groups'])
|
|
print(json.dumps(report,ensure_ascii=False))
|
|
|
|
|
|
if __name__=='__main__':main()
|